From 5bc63e3ba826e60188be480d6b7e726821e00612 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 12 Nov 2025 09:44:59 +0100 Subject: [PATCH 01/10] build(deps): update core package reference - Update core package reference from 93ca190 to 064c438 - This change affects both pubspec.lock and pubspec.yaml files --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index a27365d..1023f8e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -117,8 +117,8 @@ packages: dependency: "direct main" description: path: "." - ref: "93ca1905f72d708a156721519d663b2853b865f3" - resolved-ref: "93ca1905f72d708a156721519d663b2853b865f3" + ref: "064c4387b3f7df835565c41c918dc2d80dd2f49a" + resolved-ref: "064c4387b3f7df835565c41c918dc2d80dd2f49a" url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index 50722e7..cfd093c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,7 +59,7 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: 93ca1905f72d708a156721519d663b2853b865f3 + ref: 064c4387b3f7df835565c41c918dc2d80dd2f49a http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git From 0f3cb81d55541f60cb57b5b416778f75cb36ecab Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 12 Nov 2025 14:45:06 +0100 Subject: [PATCH 02/10] feat(db): add migration to refactor user preferences and remote config Creates a new database migration to align the schema with the updated `core` package models. - Adds `savedHeadlineFilters` and `savedSourceFilters` to `user_content_preferences` and removes the old `interests` field. - Refactors `remote_configs` to use the new map-based `UserPreferenceConfig` structure and removes the deprecated `interestConfig`. - Implements both `up` and `down` methods for schema transformation and rollback. --- ...or_user_preferences_and_remote_config.dart | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 lib/src/database/migrations/20251112000000_refactor_user_preferences_and_remote_config.dart diff --git a/lib/src/database/migrations/20251112000000_refactor_user_preferences_and_remote_config.dart b/lib/src/database/migrations/20251112000000_refactor_user_preferences_and_remote_config.dart new file mode 100644 index 0000000..0e7811f --- /dev/null +++ b/lib/src/database/migrations/20251112000000_refactor_user_preferences_and_remote_config.dart @@ -0,0 +1,160 @@ +import 'package:core/core.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart'; +import 'package:logging/logging.dart'; +import 'package:mongo_dart/mongo_dart.dart'; + +/// {@template refactor_user_preferences_and_remote_config} +/// A migration to refactor the database schema to align with the updated +/// `UserContentPreferences` and `RemoteConfig` models from the core package. +/// +/// This migration performs two critical transformations: +/// +/// 1. **User Preferences Transformation:** It iterates through all +/// `user_content_preferences` documents. For each user, it adds the new +/// `savedHeadlineFilters` and `savedSourceFilters` fields as empty lists +/// and removes the now-obsolete `interests` field. +/// +/// 2. **Remote Config Transformation:** It updates the single `remote_configs` +/// document by removing the deprecated `interestConfig` and replacing the +/// individual limit fields in `userPreferenceConfig` with the new, +/// flexible role-based map structure. +/// {@endtemplate} +class RefactorUserPreferencesAndRemoteConfig extends Migration { + /// {@macro refactor_user_preferences_and_remote_config} + RefactorUserPreferencesAndRemoteConfig() + : super( + prDate: '20251112000000', + prId: '78', + prSummary: + 'Refactors UserContentPreferences and RemoteConfig to support new SavedFilter models.', + ); + + @override + Future up(Db db, Logger log) async { + log.info('Starting migration: RefactorUserPreferencesAndRemoteConfig.up'); + + // --- 1. Migrate user_content_preferences --- + log.info('Migrating user_content_preferences collection...'); + final preferencesCollection = db.collection('user_content_preferences'); + final result = await preferencesCollection.updateMany( + where.exists('interests'), + modify + .set('savedHeadlineFilters', []) + .set('savedSourceFilters', []) + .unset('interests'), + ); + log.info( + 'Updated user_content_preferences: ${result.nModified} documents modified.', + ); + + // --- 2. Migrate remote_configs --- + log.info('Migrating remote_configs collection...'); + final remoteConfigCollection = db.collection('remote_configs'); + final remoteConfig = await remoteConfigCollection.findOne(); + + if (remoteConfig != null) { + // Define the new UserPreferenceConfig structure based on the new model. + // This uses the structure from the "NEW REMOTE CONFIG" example. + const newConfig = UserPreferenceConfig( + followedItemsLimit: { + AppUserRole.guestUser: 5, + AppUserRole.standardUser: 15, + AppUserRole.premiumUser: 30, + }, + savedHeadlinesLimit: { + AppUserRole.guestUser: 10, + AppUserRole.standardUser: 30, + AppUserRole.premiumUser: 100, + }, + savedHeadlineFiltersLimit: { + AppUserRole.guestUser: SavedFilterLimits( + total: 3, + pinned: 3, + notificationSubscriptions: { + PushNotificationSubscriptionDeliveryType.breakingOnly: 1, + PushNotificationSubscriptionDeliveryType.dailyDigest: 0, + PushNotificationSubscriptionDeliveryType.weeklyRoundup: 0, + }, + ), + AppUserRole.standardUser: SavedFilterLimits( + total: 10, + pinned: 5, + notificationSubscriptions: { + PushNotificationSubscriptionDeliveryType.breakingOnly: 3, + PushNotificationSubscriptionDeliveryType.dailyDigest: 2, + PushNotificationSubscriptionDeliveryType.weeklyRoundup: 2, + }, + ), + AppUserRole.premiumUser: SavedFilterLimits( + total: 25, + pinned: 10, + notificationSubscriptions: { + PushNotificationSubscriptionDeliveryType.breakingOnly: 10, + PushNotificationSubscriptionDeliveryType.dailyDigest: 10, + PushNotificationSubscriptionDeliveryType.weeklyRoundup: 10, + }, + ), + }, + savedSourceFiltersLimit: { + AppUserRole.guestUser: SavedFilterLimits(total: 3, pinned: 3), + AppUserRole.standardUser: SavedFilterLimits(total: 10, pinned: 5), + AppUserRole.premiumUser: SavedFilterLimits(total: 25, pinned: 10), + }, + ); + + await remoteConfigCollection.updateOne( + where.id(remoteConfig['_id'] as ObjectId), + modify + // Set the entire userPreferenceConfig to the new structure + .set('userPreferenceConfig', newConfig.toJson()) + // Remove the obsolete interestConfig + .unset('interestConfig'), + ); + log.info('Successfully migrated remote_configs document.'); + } else { + log.warning('Remote config document not found. Skipping migration.'); + } + + log.info('Migration RefactorUserPreferencesAndRemoteConfig.up completed.'); + } + + @override + Future down(Db db, Logger log) async { + log.warning( + 'Executing "down" for RefactorUserPreferencesAndRemoteConfig. ' + 'This is a destructive operation and may result in data loss.', + ); + + // --- 1. Revert user_content_preferences --- + final preferencesCollection = db.collection('user_content_preferences'); + await preferencesCollection.updateMany( + where.exists('savedHeadlineFilters'), // Target documents to revert + modify + .unset('savedHeadlineFilters') + .unset('savedSourceFilters') + .set('interests', []), + ); + log.info( + 'Reverted user_content_preferences: removed new filter fields and ' + 're-added empty "interests" field.', + ); + + // --- 2. Revert remote_configs --- + // This is a best-effort revert and will not restore the exact previous + // state but will remove the new fields. + final remoteConfigCollection = db.collection('remote_configs'); + await remoteConfigCollection.updateMany( + where.exists('userPreferenceConfig.followedItemsLimit'), + modify + .unset('userPreferenceConfig') + .set('interestConfig', {}), + ); + log.info( + 'Reverted remote_configs: removed new userPreferenceConfig structure.', + ); + + log.info( + 'Migration RefactorUserPreferencesAndRemoteConfig.down completed.', + ); + } +} From 8c3fea9363affc27c2b67882769d4669f6eedf3f Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 12 Nov 2025 14:46:18 +0100 Subject: [PATCH 03/10] refactor(db): update migration registry with new schema changes Removes the old `UnifyInterestsAndRemoteConfig` migration and registers the new `RefactorUserPreferencesAndRemoteConfig` migration. This ensures the database migration service applies the correct schema transformation. --- ...000_unify_interests_and_remote_config.dart | 229 ------------------ .../database/migrations/all_migrations.dart | 4 +- 2 files changed, 2 insertions(+), 231 deletions(-) delete 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 deleted file mode 100644 index a4050d8..0000000 --- a/lib/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart +++ /dev/null @@ -1,229 +0,0 @@ -import 'package:core/core.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart'; -import 'package:logging/logging.dart'; -import 'package:mongo_dart/mongo_dart.dart'; - -/// {@template unify_interests_and_remote_config} -/// A migration to refactor the database schema by unifying `SavedFilter` and -/// `PushNotificationSubscription` into a single `Interest` model. -/// -/// This migration performs two critical transformations: -/// -/// 1. **User Preferences Transformation:** It iterates through all -/// `user_content_preferences` documents. For each user, it reads the -/// legacy `savedFilters` and `notificationSubscriptions` arrays, converts -/// them into the new `Interest` format, and merges them. It then saves -/// this new list to an `interests` field and removes the old, obsolete -/// arrays. -/// -/// 2. **Remote Config Transformation:** It updates the single `remote_configs` -/// document by adding the new `interestConfig` field with default limits -/// and removing the now-deprecated limit fields from `userPreferenceConfig` -/// and `pushNotificationConfig`. -/// {@endtemplate} -class UnifyInterestsAndRemoteConfig extends Migration { - /// {@macro unify_interests_and_remote_config} - UnifyInterestsAndRemoteConfig() - : super( - prDate: '20251111000000', - prId: '74', - prSummary: - 'This pull request introduces a significant new Interest feature, designed to enhance user personalization by unifying content filtering and notification subscriptions.', - ); - - @override - Future up(Db db, Logger log) async { - log.info('Starting migration: UnifyInterestsAndRemoteConfig.up'); - - // --- 1. Migrate user_content_preferences --- - log.info('Migrating user_content_preferences collection...'); - final preferencesCollection = db.collection('user_content_preferences'); - final allPreferences = await preferencesCollection.find().toList(); - - for (final preferenceDoc in allPreferences) { - final userId = (preferenceDoc['_id'] as ObjectId).oid; - log.finer('Processing preferences for user: $userId'); - - final savedFilters = - (preferenceDoc['savedFilters'] as List? ?? []) - .map((e) => e as Map) - .toList(); - final notificationSubscriptions = - (preferenceDoc['notificationSubscriptions'] as List? ?? []) - .map((e) => e as Map) - .toList(); - - if (savedFilters.isEmpty && notificationSubscriptions.isEmpty) { - log.finer('User $userId has no legacy data to migrate. Skipping.'); - continue; - } - - // Use a map to merge filters and subscriptions with the same criteria. - final interestMap = {}; - - // Process saved filters - for (final filter in savedFilters) { - final criteriaData = filter['criteria']; - if (criteriaData is! Map) { - log.warning( - 'User $userId has a malformed savedFilter with missing or invalid ' - '"criteria". Skipping this filter.', - ); - continue; - } - - final criteria = InterestCriteria.fromJson(criteriaData); - final key = _generateCriteriaKey(criteria); - - interestMap.update( - key, - (existing) => existing.copyWith(isPinnedFeedFilter: true), - ifAbsent: () => Interest( - id: ObjectId().oid, - userId: userId, - name: filter['name'] as String, - criteria: criteria, - isPinnedFeedFilter: true, - deliveryTypes: const {}, - ), - ); - } - - // Process notification subscriptions - for (final subscription in notificationSubscriptions) { - final criteriaData = subscription['criteria']; - if (criteriaData is! Map) { - log.warning( - 'User $userId has a malformed notificationSubscription with ' - 'missing or invalid "criteria". Skipping this subscription.', - ); - continue; - } - - final criteria = InterestCriteria.fromJson(criteriaData); - final key = _generateCriteriaKey(criteria); - final deliveryTypes = - (subscription['deliveryTypes'] as List? ?? []) - .map((e) { - try { - return PushNotificationSubscriptionDeliveryType.values - .byName(e as String); - } catch (_) { - log.warning( - 'User $userId has a notificationSubscription with an invalid deliveryType: "$e". Skipping this type.', - ); - return null; - } - }) - .whereType() - .toSet(); - - interestMap.update( - key, - (existing) => existing.copyWith( - deliveryTypes: {...existing.deliveryTypes, ...deliveryTypes}, - ), - ifAbsent: () => Interest( - id: ObjectId().oid, - userId: userId, - name: subscription['name'] as String, - criteria: criteria, - isPinnedFeedFilter: false, - deliveryTypes: deliveryTypes, - ), - ); - } - - final newInterests = interestMap.values.map((i) => i.toJson()).toList(); - - await preferencesCollection.updateOne( - where.id(preferenceDoc['_id'] as ObjectId), - modify - .set('interests', newInterests) - .unset('savedFilters') - .unset('notificationSubscriptions'), - ); - log.info( - 'Successfully migrated ${newInterests.length} interests for user $userId.', - ); - } - - // --- 2. Migrate remote_configs --- - log.info('Migrating remote_configs collection...'); - final remoteConfigCollection = db.collection('remote_configs'); - final remoteConfig = await remoteConfigCollection.findOne(); - - if (remoteConfig != null) { - // Use the default from the core package fixtures as the base. - final defaultConfig = remoteConfigsFixturesData.first.interestConfig; - - await remoteConfigCollection.updateOne( - where.id(remoteConfig['_id'] as ObjectId), - modify - .set('interestConfig', defaultConfig.toJson()) - .unset('userPreferenceConfig.guestSavedFiltersLimit') - .unset('userPreferenceConfig.authenticatedSavedFiltersLimit') - .unset('userPreferenceConfig.premiumSavedFiltersLimit') - .unset('pushNotificationConfig.deliveryConfigs'), - ); - log.info('Successfully migrated remote_configs document.'); - } else { - log.warning('Remote config document not found. Skipping migration.'); - } - - log.info('Migration UnifyInterestsAndRemoteConfig.up completed.'); - } - - @override - Future down(Db db, Logger log) async { - log.warning( - 'Executing "down" for UnifyInterestsAndRemoteConfig. ' - 'This is a destructive operation and may result in data loss.', - ); - - // --- 1. Revert user_content_preferences --- - final preferencesCollection = db.collection('user_content_preferences'); - await preferencesCollection.updateMany( - where.exists('interests'), - modify - .unset('interests') - .set('savedFilters', []) - .set('notificationSubscriptions', []), - ); - log.info( - 'Removed "interests" field and re-added empty legacy fields to all ' - 'user_content_preferences documents.', - ); - - // --- 2. Revert remote_configs --- - final remoteConfigCollection = db.collection('remote_configs'); - await remoteConfigCollection.updateMany( - where.exists('interestConfig'), - modify - .unset('interestConfig') - .set('userPreferenceConfig.guestSavedFiltersLimit', 5) - .set('userPreferenceConfig.authenticatedSavedFiltersLimit', 20) - .set('userPreferenceConfig.premiumSavedFiltersLimit', 50) - .set( - 'pushNotificationConfig.deliveryConfigs', - { - 'breakingOnly': true, - 'dailyDigest': true, - 'weeklyRoundup': true, - }, - ), - ); - log.info('Reverted remote_configs document to legacy structure.'); - - log.info('Migration UnifyInterestsAndRemoteConfig.down completed.'); - } - - /// Generates a stable, sorted key from interest criteria to identify - /// duplicates. - String _generateCriteriaKey(InterestCriteria criteria) { - final topics = criteria.topics.map((t) => t.id).toList()..sort(); - final sources = criteria.sources.map((s) => s.id).toList()..sort(); - final countries = criteria.countries.map((c) => c.id).toList()..sort(); - return 't:${topics.join(',')};s:${sources.join(',')};c:${countries.join(',')}'; - } -} diff --git a/lib/src/database/migrations/all_migrations.dart b/lib/src/database/migrations/all_migrations.dart index a72b8d9..454ebd2 100644 --- a/lib/src/database/migrations/all_migrations.dart +++ b/lib/src/database/migrations/all_migrations.dart @@ -6,7 +6,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/database/migrat import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251103073226_remove_local_ad_platform.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251107000000_add_is_breaking_to_headlines.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251108103300_add_push_notification_config_to_remote_config.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251112000000_refactor_user_preferences_and_remote_config.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart' show DatabaseMigrationService; @@ -23,5 +23,5 @@ final List allMigrations = [ RemoveLocalAdPlatform(), AddIsBreakingToHeadlines(), AddPushNotificationConfigToRemoteConfig(), - UnifyInterestsAndRemoteConfig(), + RefactorUserPreferencesAndRemoteConfig(), ]; From 870dec578aee41c7d569f47f33bb6c887bb624c2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 12 Nov 2025 14:48:01 +0100 Subject: [PATCH 04/10] refactor(auth): adapt user creation to new preference models Updates the `_ensureUserDataExists` method in `AuthService` to use the new `UserContentPreferences` constructor. This removes the deprecated `interests` field and adds the required `savedHeadlineFilters` and `savedSourceFilters` fields, ensuring new users are initialized with the correct data structure. --- lib/src/services/auth_service.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 734d89f..1b65484 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -558,13 +558,15 @@ class AuthService { _log.info( 'UserContentPreferences not found for user ${user.id}. Creating with defaults.', ); + // Initialize with empty lists for all user-managed content. final defaultUserPreferences = UserContentPreferences( id: user.id, followedCountries: const [], followedSources: const [], followedTopics: const [], savedHeadlines: const [], - interests: const [], + savedHeadlineFilters: const [], + savedSourceFilters: const [], ); await _userContentPreferencesRepository.create( item: defaultUserPreferences, From e08b1f42396e37c89c9b3312238ae740a0f295b4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 12 Nov 2025 14:52:10 +0100 Subject: [PATCH 05/10] refactor(db): adapt user seeding to new preference models Updates the `_createUserSubDocuments` method in `DatabaseSeedingService` to use the new `UserContentPreferences` constructor. This removes the deprecated `interests` field and adds the required `savedHeadlineFilters` and `savedSourceFilters` fields, ensuring default user data is correctly structured. --- lib/src/services/database_seeding_service.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 26ca56f..295a7f5 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -389,13 +389,15 @@ class DatabaseSeedingService { ...defaultAppSettings.toJson()..remove('id'), }); + // Initialize with empty lists for all user-managed content. final defaultUserPreferences = UserContentPreferences( id: userId.oid, followedCountries: const [], followedSources: const [], followedTopics: const [], savedHeadlines: const [], - interests: const [], + savedHeadlineFilters: const [], + savedSourceFilters: const [], ); await _db.collection('user_content_preferences').insertOne({ '_id': userId, From 0378a35bf000c7e50fb8a65d308ffb46312b7964 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 12 Nov 2025 14:54:24 +0100 Subject: [PATCH 06/10] refactor(limits): adapt user preference limits to new config structure Updates `DefaultUserPreferenceLimitService` to enforce limits using the new map-based `UserPreferenceConfig` from `RemoteConfig`. This includes validating `followedItemsLimit`, `savedHeadlinesLimit`, `savedHeadlineFiltersLimit`, and `savedSourceFiltersLimit` against user roles. Removes reliance on the deprecated `interestConfig` and individual limit properties. --- ...default_user_preference_limit_service.dart | 194 ++++++++++++------ 1 file changed, 126 insertions(+), 68 deletions(-) diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index ecd6654..5bccbe0 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -35,7 +35,13 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ); final limits = remoteConfig.userPreferenceConfig; - final (followedItemsLimit, savedHeadlinesLimit) = _getLimitsForRole( + // Retrieve all relevant limits for the user's role from the remote configuration. + final ( + followedItemsLimit, + savedHeadlinesLimit, + savedHeadlineFiltersLimit, + savedSourceFiltersLimit, + ) = _getLimitsForRole( user.appRole, limits, ); @@ -85,73 +91,100 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ); } - // --- 2. Check interest-specific limits --- - final interestLimits = remoteConfig.interestConfig.limits[user.appRole]; - if (interestLimits == null) { + // --- 2. Check saved headline filter limits --- + // Validate the total number of saved headline filters. + if (updatedPreferences.savedHeadlineFilters.length > + savedHeadlineFiltersLimit.total) { _log.severe( - 'Interest limits not found for role ${user.appRole}. ' - 'Denying request by default.', + 'User ${user.id} exceeded total saved headline filter limit: ' + '${savedHeadlineFiltersLimit.total} (attempted ' + '${updatedPreferences.savedHeadlineFilters.length}).', + ); + throw ForbiddenException( + 'You have reached your limit of ${savedHeadlineFiltersLimit.total} ' + 'saved headline filters.', ); - throw const ForbiddenException('Interest limits are not configured.'); } - // Check total number of interests. - if (updatedPreferences.interests.length > interestLimits.total) { + // Validate the number of pinned saved headline filters. + final pinnedHeadlineFilterCount = updatedPreferences.savedHeadlineFilters + .where((f) => f.isPinned) + .length; + if (pinnedHeadlineFilterCount > savedHeadlineFiltersLimit.pinned) { _log.warning( - 'User ${user.id} exceeded total interest limit: ' - '${interestLimits.total} (attempted ' - '${updatedPreferences.interests.length}).', + 'User ${user.id} exceeded pinned saved headline filter limit: ' + '${savedHeadlineFiltersLimit.pinned} (attempted $pinnedHeadlineFilterCount).', ); throw ForbiddenException( - 'You have reached your limit of ${interestLimits.total} saved interests.', + 'You have reached your limit of ${savedHeadlineFiltersLimit.pinned} ' + 'pinned saved headline filters.', ); } - // Check total number of pinned feed filters. - final pinnedCount = updatedPreferences.interests - .where((i) => i.isPinnedFeedFilter) - .length; - if (pinnedCount > interestLimits.pinnedFeedFilters) { + // Validate notification subscription limits for each delivery type for saved headline filters. + if (savedHeadlineFiltersLimit.notificationSubscriptions != null) { + for (final deliveryType + in PushNotificationSubscriptionDeliveryType.values) { + final notificationLimit = + savedHeadlineFiltersLimit.notificationSubscriptions![deliveryType]; + if (notificationLimit == null) { + // This indicates a misconfiguration in RemoteConfig if a deliveryType is expected but not present. + _log.severe( + 'Notification limit for type ${deliveryType.name} not configured for ' + 'role ${user.appRole} in savedHeadlineFiltersLimit. Denying request.', + ); + throw ForbiddenException( + 'Notification limits for ${deliveryType.name} are not configured ' + 'for saved headline filters.', + ); + } + + final subscriptionCount = updatedPreferences.savedHeadlineFilters + .where((f) => f.deliveryTypes.contains(deliveryType)) + .length; + + if (subscriptionCount > notificationLimit) { + _log.warning( + 'User ${user.id} exceeded notification limit for ' + '${deliveryType.name} in saved headline filters: $notificationLimit ' + '(attempted $subscriptionCount).', + ); + throw ForbiddenException( + 'You have reached your limit of $notificationLimit ' + '${deliveryType.name} notification subscriptions for saved headline filters.', + ); + } + } + } + + // --- 3. Check saved source filter limits --- + // Validate the total number of saved source filters. + if (updatedPreferences.savedSourceFilters.length > + savedSourceFiltersLimit.total) { _log.warning( - 'User ${user.id} exceeded pinned feed filter limit: ' - '${interestLimits.pinnedFeedFilters} (attempted $pinnedCount).', + 'User ${user.id} exceeded total saved source filter limit: ' + '${savedSourceFiltersLimit.total} (attempted ' + '${updatedPreferences.savedSourceFilters.length}).', ); throw ForbiddenException( - 'You have reached your limit of ${interestLimits.pinnedFeedFilters} ' - 'pinned feed filters.', + 'You have reached your limit of ${savedSourceFiltersLimit.total} ' + 'saved source filters.', ); } - // Check notification subscription limits for each possible delivery type. - for (final deliveryType - in PushNotificationSubscriptionDeliveryType.values) { - final notificationLimit = interestLimits.notifications[deliveryType]; - if (notificationLimit == null) { - _log.severe( - 'Notification limit for type ${deliveryType.name} not found for ' - 'role ${user.appRole}. Denying request by default.', - ); - throw ForbiddenException( - 'Notification limits for ${deliveryType.name} are not configured.', - ); - } - - // Count how many of the user's proposed interests include this type. - final subscriptionCount = updatedPreferences.interests - .where((i) => i.deliveryTypes.contains(deliveryType)) - .length; - - if (subscriptionCount > notificationLimit) { - _log.warning( - 'User ${user.id} exceeded notification limit for ' - '${deliveryType.name}: $notificationLimit ' - '(attempted $subscriptionCount).', - ); - throw ForbiddenException( - 'You have reached your limit of $notificationLimit ' - '${deliveryType.name} notification subscriptions.', - ); - } + // Validate the number of pinned saved source filters. + final pinnedSourceFilterCount = updatedPreferences.savedSourceFilters + .where((f) => f.isPinned) + .length; + if (pinnedSourceFilterCount > savedSourceFiltersLimit.pinned) { + _log.warning( + 'User ${user.id} exceeded pinned saved source filter limit: ' + '${savedSourceFiltersLimit.pinned} (attempted $pinnedSourceFilterCount).', + ); + throw ForbiddenException( + 'You have reached your limit of ${savedSourceFiltersLimit.pinned} ' + 'pinned saved source filters.', + ); } _log.info( @@ -159,24 +192,49 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ); } - /// Helper to get the correct limits based on the user's role. - (int, int) _getLimitsForRole( + /// Helper to retrieve all relevant user preference limits based on the user's role. + /// + /// Throws [StateError] if a required limit is not configured for the given role, + /// indicating a misconfiguration in the remote config. + ( + int followedItemsLimit, + int savedHeadlinesLimit, + SavedFilterLimits savedHeadlineFiltersLimit, + SavedFilterLimits savedSourceFiltersLimit, + ) + _getLimitsForRole( AppUserRole role, UserPreferenceConfig limits, ) { - return switch (role) { - AppUserRole.guestUser => ( - limits.guestFollowedItemsLimit, - limits.guestSavedHeadlinesLimit, - ), - AppUserRole.standardUser => ( - limits.authenticatedFollowedItemsLimit, - limits.authenticatedSavedHeadlinesLimit, - ), - AppUserRole.premiumUser => ( - limits.premiumFollowedItemsLimit, - limits.premiumSavedHeadlinesLimit, - ), - }; + final followedItemsLimit = limits.followedItemsLimit[role]; + if (followedItemsLimit == null) { + throw StateError('Followed items limit not configured for role: $role'); + } + + final savedHeadlinesLimit = limits.savedHeadlinesLimit[role]; + if (savedHeadlinesLimit == null) { + throw StateError('Saved headlines limit not configured for role: $role'); + } + + final savedHeadlineFiltersLimit = limits.savedHeadlineFiltersLimit[role]; + if (savedHeadlineFiltersLimit == null) { + throw StateError( + 'Saved headline filters limit not configured for role: $role', + ); + } + + final savedSourceFiltersLimit = limits.savedSourceFiltersLimit[role]; + if (savedSourceFiltersLimit == null) { + throw StateError( + 'Saved source filters limit not configured for role: $role', + ); + } + + return ( + followedItemsLimit, + savedHeadlinesLimit, + savedHeadlineFiltersLimit, + savedSourceFiltersLimit, + ); } } From 7f423861f6d90479ae199734533e47aeaee5165b Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 12 Nov 2025 14:56:34 +0100 Subject: [PATCH 07/10] refactor(notifications): update breaking news subscription query Updates the `sendBreakingNewsNotification` method in `DefaultPushNotificationService` to query `user_content_preferences` using the new `savedHeadlineFilters.deliveryTypes` field. This aligns the notification subscription logic with the updated `UserContentPreferences` model, replacing the deprecated `interests` field. --- lib/src/services/push_notification_service.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index 992b035..78ff93a 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -117,12 +117,12 @@ class DefaultPushNotificationService implements IPushNotificationService { return; } - // 2. Find all user preferences that contain an interest subscribed to - // breaking news. This query targets the embedded 'interests' array. + // 2. Find all user preferences that contain a saved headline filter + // subscribed to breaking news. This query targets the embedded 'savedHeadlineFilters' array. final subscribedUserPreferences = await _userContentPreferencesRepository .readAll( filter: { - 'interests.deliveryTypes': { + 'savedHeadlineFilters.deliveryTypes': { r'$in': [ PushNotificationSubscriptionDeliveryType.breakingOnly.name, ], From 19299917af45fd651aa68734206bb3f375298d01 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 12 Nov 2025 15:08:46 +0100 Subject: [PATCH 08/10] refactor(rbac): remove local ad permissions and role assignments - Remove local ad related permissions from permissions.dart - Remove local ad related permissions from role_permissions.dart - Update guest and admin role permissions accordingly --- lib/src/rbac/permissions.dart | 6 ------ lib/src/rbac/role_permissions.dart | 6 ------ 2 files changed, 12 deletions(-) diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 92bb710..5ca56f6 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -73,12 +73,6 @@ abstract class Permissions { static const String userPreferenceBypassLimits = 'user_preference.bypass_limits'; - // Local Ad Permissions - static const String localAdCreate = 'local_ad.create'; - static const String localAdRead = 'local_ad.read'; - static const String localAdUpdate = 'local_ad.update'; - static const String localAdDelete = 'local_ad.delete'; - // General System Permissions static const String rateLimitingBypass = 'rate_limiting.bypass'; diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 04c5fad..5984237 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -14,7 +14,6 @@ final Set _appGuestUserPermissions = { Permissions.userContentPreferencesReadOwned, Permissions.userContentPreferencesUpdateOwned, Permissions.remoteConfigRead, - Permissions.localAdRead, // Allows a user to update their own User object. This is essential for // features like updating the `feedActionStatus` (e.g., when a user // dismisses an in-feed prompt, etc). The endpoint handler ensures only @@ -90,11 +89,6 @@ final Set _dashboardAdminPermissions = { Permissions.remoteConfigUpdate, Permissions.remoteConfigDelete, Permissions.userPreferenceBypassLimits, - // Added localAd CRUD permissions for admins - Permissions.localAdCreate, - Permissions.localAdRead, - Permissions.localAdUpdate, - Permissions.localAdDelete, }; /// Defines the mapping between user roles (both app and dashboard) and the From bbdd4a680cca3d1f200eb8fb5450404c95e63f5b Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 12 Nov 2025 15:14:11 +0100 Subject: [PATCH 09/10] docs(README): update notification streams feature description - Rename "Interests" to "Saved Headline Filters" in the description of user-crafted notification streams - Adjust wording to reflect that users subscribe to notifications for a specific filter --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e3c893..4e2b33a 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ The API automatically validates the structure of all incoming data, ensuring tha ### 📲 Dynamic & Personalized Notifications A complete, multi-provider notification engine that empowers you to engage users with timely, relevant, and personalized alerts. - **Editorial-Driven Alerts:** Any piece of content can be designated as "breaking news" from the content dashboard, triggering immediate, high-priority alerts to subscribed users. -- **User-Crafted Notification Streams:** Users can create and save persistent **Interests** based on any combination of content filters (such as topics, sources, or regions). They can then subscribe to notifications for that interest, receiving alerts only for the news they care about. +- **User-Crafted Notification Streams:** Users can create and save persistent **Saved Headline Filters** based on any combination of content filters (such as topics, sources, or regions). They can then subscribe to notifications for that filter, receiving alerts only for the news they care about. - **Flexible Delivery Mechanisms:** The system is architected to support multiple notification types for each subscription, from immediate alerts to scheduled daily or weekly digests. - **Provider Agnostic:** The engine is built to be provider-agnostic, with out-of-the-box support for Firebase (FCM) and OneSignal. The active provider can be switched remotely without any code changes. > **Your Advantage:** You get a complete, secure, and scalable notification system that enhances user engagement and can be managed entirely from the web dashboard. From 271c8ce4dfb8ccc4ed38c9706e3763ac47143c66 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 12 Nov 2025 15:48:50 +0100 Subject: [PATCH 10/10] fix(default-user-preference-limit): change log level for saved headline filters limit - Change log level from 'severe' to 'warning' when user exceeds the total saved headline filter limit - This modification provides a less critical log level for this specific scenario while still alerting administrators --- lib/src/services/default_user_preference_limit_service.dart | 2 +- 1 file changed, 1 insertion(+), 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 5bccbe0..e182cca 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -95,7 +95,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { // Validate the total number of saved headline filters. if (updatedPreferences.savedHeadlineFilters.length > savedHeadlineFiltersLimit.total) { - _log.severe( + _log.warning( 'User ${user.id} exceeded total saved headline filter limit: ' '${savedHeadlineFiltersLimit.total} (attempted ' '${updatedPreferences.savedHeadlineFilters.length}).',