Skip to content

Commit 9200f61

Browse files
authored
Merge pull request #94 from flutter-news-app-full-source-code/refactor/sync-with-core-update
Refactor/sync with core update
2 parents 65d9111 + acb356e commit 9200f61

20 files changed

+579
-301
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ The API automatically validates the structure of all incoming data, ensuring tha
7676
### 📲 Dynamic & Personalized Notifications
7777
A complete, multi-provider notification engine that empowers you to engage users with timely, relevant, and personalized alerts.
7878
- **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.
79-
- **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.
79+
- **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.
8080
- **Flexible Delivery Mechanisms:** The system is architected to support multiple notification types for each subscription, from immediate alerts to scheduled daily or weekly digests.
8181
- **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.
8282
> **Your Advantage:** You get a complete, secure, and scalable notification system that enhances user engagement and can be managed entirely from the web dashboard.

lib/src/config/app_dependencies.dart

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ class AppDependencies {
7070
userContentPreferencesRepository;
7171
late final DataRepository<PushNotificationDevice>
7272
pushNotificationDeviceRepository;
73-
late final DataRepository<PushNotificationSubscription>
74-
pushNotificationSubscriptionRepository;
7573
late final DataRepository<RemoteConfig> remoteConfigRepository;
74+
late final DataRepository<InAppNotification> inAppNotificationRepository;
75+
7676
late final EmailRepository emailRepository;
7777

7878
// Services
@@ -220,14 +220,16 @@ class AppDependencies {
220220
toJson: (item) => item.toJson(),
221221
logger: Logger('DataMongodb<PushNotificationDevice>'),
222222
);
223-
final pushNotificationSubscriptionClient =
224-
DataMongodb<PushNotificationSubscription>(
225-
connectionManager: _mongoDbConnectionManager,
226-
modelName: 'push_notification_subscriptions',
227-
fromJson: PushNotificationSubscription.fromJson,
228-
toJson: (item) => item.toJson(),
229-
logger: Logger('DataMongodb<PushNotificationSubscription>'),
230-
);
223+
224+
final inAppNotificationClient = DataMongodb<InAppNotification>(
225+
connectionManager: _mongoDbConnectionManager,
226+
modelName: 'in_app_notifications',
227+
fromJson: InAppNotification.fromJson,
228+
toJson: (item) => item.toJson(),
229+
logger: Logger('DataMongodb<InAppNotification>'),
230+
);
231+
232+
_log.info('Initialized data client for InAppNotification.');
231233

232234
// --- Conditionally Initialize Push Notification Clients ---
233235

@@ -314,8 +316,8 @@ class AppDependencies {
314316
pushNotificationDeviceRepository = DataRepository(
315317
dataClient: pushNotificationDeviceClient,
316318
);
317-
pushNotificationSubscriptionRepository = DataRepository(
318-
dataClient: pushNotificationSubscriptionClient,
319+
inAppNotificationRepository = DataRepository(
320+
dataClient: inAppNotificationClient,
319321
);
320322
// Configure the HTTP client for SendGrid.
321323
// The HttpClient's AuthInterceptor will use the tokenProvider to add
@@ -368,7 +370,6 @@ class AppDependencies {
368370
);
369371
userPreferenceLimitService = DefaultUserPreferenceLimitService(
370372
remoteConfigRepository: remoteConfigRepository,
371-
permissionService: permissionService,
372373
log: Logger('DefaultUserPreferenceLimitService'),
373374
);
374375
rateLimitService = MongoDbRateLimitService(
@@ -382,8 +383,7 @@ class AppDependencies {
382383
);
383384
pushNotificationService = DefaultPushNotificationService(
384385
pushNotificationDeviceRepository: pushNotificationDeviceRepository,
385-
pushNotificationSubscriptionRepository:
386-
pushNotificationSubscriptionRepository,
386+
userContentPreferencesRepository: userContentPreferencesRepository,
387387
remoteConfigRepository: remoteConfigRepository,
388388
firebaseClient: firebasePushNotificationClient,
389389
oneSignalClient: oneSignalPushNotificationClient,
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import 'package:core/core.dart';
2+
import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart';
3+
import 'package:logging/logging.dart';
4+
import 'package:mongo_dart/mongo_dart.dart';
5+
6+
/// {@template unify_interests_and_remote_config}
7+
/// A migration to refactor the database schema by unifying `SavedFilter` and
8+
/// `PushNotificationSubscription` into a single `Interest` model.
9+
///
10+
/// This migration performs two critical transformations:
11+
///
12+
/// 1. **User Preferences Transformation:** It iterates through all
13+
/// `user_content_preferences` documents. For each user, it reads the
14+
/// legacy `savedFilters` and `notificationSubscriptions` arrays, converts
15+
/// them into the new `Interest` format, and merges them. It then saves
16+
/// this new list to an `interests` field and removes the old, obsolete
17+
/// arrays.
18+
///
19+
/// 2. **Remote Config Transformation:** It updates the single `remote_configs`
20+
/// document by adding the new `interestConfig` field with default limits
21+
/// and removing the now-deprecated limit fields from `userPreferenceConfig`
22+
/// and `pushNotificationConfig`.
23+
/// {@endtemplate}
24+
class UnifyInterestsAndRemoteConfig extends Migration {
25+
/// {@macro unify_interests_and_remote_config}
26+
UnifyInterestsAndRemoteConfig()
27+
: super(
28+
prDate: '20251111000000',
29+
prId: '74',
30+
prSummary:
31+
'This pull request introduces a significant new Interest feature, designed to enhance user personalization by unifying content filtering and notification subscriptions.',
32+
);
33+
34+
@override
35+
Future<void> up(Db db, Logger log) async {
36+
log.info('Starting migration: UnifyInterestsAndRemoteConfig.up');
37+
38+
// --- 1. Migrate user_content_preferences ---
39+
log.info('Migrating user_content_preferences collection...');
40+
final preferencesCollection = db.collection('user_content_preferences');
41+
final allPreferences = await preferencesCollection.find().toList();
42+
43+
for (final preferenceDoc in allPreferences) {
44+
final userId = (preferenceDoc['_id'] as ObjectId).oid;
45+
log.finer('Processing preferences for user: $userId');
46+
47+
final savedFilters =
48+
(preferenceDoc['savedFilters'] as List<dynamic>? ?? [])
49+
.map((e) => e as Map<String, dynamic>)
50+
.toList();
51+
final notificationSubscriptions =
52+
(preferenceDoc['notificationSubscriptions'] as List<dynamic>? ?? [])
53+
.map((e) => e as Map<String, dynamic>)
54+
.toList();
55+
56+
if (savedFilters.isEmpty && notificationSubscriptions.isEmpty) {
57+
log.finer('User $userId has no legacy data to migrate. Skipping.');
58+
continue;
59+
}
60+
61+
// Use a map to merge filters and subscriptions with the same criteria.
62+
final interestMap = <String, Interest>{};
63+
64+
// Process saved filters
65+
for (final filter in savedFilters) {
66+
final criteriaData = filter['criteria'];
67+
if (criteriaData is! Map<String, dynamic>) {
68+
log.warning(
69+
'User $userId has a malformed savedFilter with missing or invalid '
70+
'"criteria". Skipping this filter.',
71+
);
72+
continue;
73+
}
74+
75+
final criteria = InterestCriteria.fromJson(criteriaData);
76+
final key = _generateCriteriaKey(criteria);
77+
78+
interestMap.update(
79+
key,
80+
(existing) => existing.copyWith(isPinnedFeedFilter: true),
81+
ifAbsent: () => Interest(
82+
id: ObjectId().oid,
83+
userId: userId,
84+
name: filter['name'] as String,
85+
criteria: criteria,
86+
isPinnedFeedFilter: true,
87+
deliveryTypes: const {},
88+
),
89+
);
90+
}
91+
92+
// Process notification subscriptions
93+
for (final subscription in notificationSubscriptions) {
94+
final criteriaData = subscription['criteria'];
95+
if (criteriaData is! Map<String, dynamic>) {
96+
log.warning(
97+
'User $userId has a malformed notificationSubscription with '
98+
'missing or invalid "criteria". Skipping this subscription.',
99+
);
100+
continue;
101+
}
102+
103+
final criteria = InterestCriteria.fromJson(criteriaData);
104+
final key = _generateCriteriaKey(criteria);
105+
final deliveryTypes =
106+
(subscription['deliveryTypes'] as List<dynamic>? ?? [])
107+
.map((e) {
108+
try {
109+
return PushNotificationSubscriptionDeliveryType.values
110+
.byName(e as String);
111+
} catch (_) {
112+
log.warning(
113+
'User $userId has a notificationSubscription with an invalid deliveryType: "$e". Skipping this type.',
114+
);
115+
return null;
116+
}
117+
})
118+
.whereType<PushNotificationSubscriptionDeliveryType>()
119+
.toSet();
120+
121+
interestMap.update(
122+
key,
123+
(existing) => existing.copyWith(
124+
deliveryTypes: {...existing.deliveryTypes, ...deliveryTypes},
125+
),
126+
ifAbsent: () => Interest(
127+
id: ObjectId().oid,
128+
userId: userId,
129+
name: subscription['name'] as String,
130+
criteria: criteria,
131+
isPinnedFeedFilter: false,
132+
deliveryTypes: deliveryTypes,
133+
),
134+
);
135+
}
136+
137+
final newInterests = interestMap.values.map((i) => i.toJson()).toList();
138+
139+
await preferencesCollection.updateOne(
140+
where.id(preferenceDoc['_id'] as ObjectId),
141+
modify
142+
.set('interests', newInterests)
143+
.unset('savedFilters')
144+
.unset('notificationSubscriptions'),
145+
);
146+
log.info(
147+
'Successfully migrated ${newInterests.length} interests for user $userId.',
148+
);
149+
}
150+
151+
// --- 2. Migrate remote_configs ---
152+
log.info('Migrating remote_configs collection...');
153+
final remoteConfigCollection = db.collection('remote_configs');
154+
final remoteConfig = await remoteConfigCollection.findOne();
155+
156+
if (remoteConfig != null) {
157+
// Use the default from the core package fixtures as the base.
158+
final defaultConfig = remoteConfigsFixturesData.first.interestConfig;
159+
160+
await remoteConfigCollection.updateOne(
161+
where.id(remoteConfig['_id'] as ObjectId),
162+
modify
163+
.set('interestConfig', defaultConfig.toJson())
164+
.unset('userPreferenceConfig.guestSavedFiltersLimit')
165+
.unset('userPreferenceConfig.authenticatedSavedFiltersLimit')
166+
.unset('userPreferenceConfig.premiumSavedFiltersLimit')
167+
.unset('pushNotificationConfig.deliveryConfigs'),
168+
);
169+
log.info('Successfully migrated remote_configs document.');
170+
} else {
171+
log.warning('Remote config document not found. Skipping migration.');
172+
}
173+
174+
log.info('Migration UnifyInterestsAndRemoteConfig.up completed.');
175+
}
176+
177+
@override
178+
Future<void> down(Db db, Logger log) async {
179+
log.warning(
180+
'Executing "down" for UnifyInterestsAndRemoteConfig. '
181+
'This is a destructive operation and may result in data loss.',
182+
);
183+
184+
// --- 1. Revert user_content_preferences ---
185+
final preferencesCollection = db.collection('user_content_preferences');
186+
await preferencesCollection.updateMany(
187+
where.exists('interests'),
188+
modify
189+
.unset('interests')
190+
.set('savedFilters', <dynamic>[])
191+
.set('notificationSubscriptions', <dynamic>[]),
192+
);
193+
log.info(
194+
'Removed "interests" field and re-added empty legacy fields to all '
195+
'user_content_preferences documents.',
196+
);
197+
198+
// --- 2. Revert remote_configs ---
199+
final remoteConfigCollection = db.collection('remote_configs');
200+
await remoteConfigCollection.updateMany(
201+
where.exists('interestConfig'),
202+
modify
203+
.unset('interestConfig')
204+
.set('userPreferenceConfig.guestSavedFiltersLimit', 5)
205+
.set('userPreferenceConfig.authenticatedSavedFiltersLimit', 20)
206+
.set('userPreferenceConfig.premiumSavedFiltersLimit', 50)
207+
.set(
208+
'pushNotificationConfig.deliveryConfigs',
209+
{
210+
'breakingOnly': true,
211+
'dailyDigest': true,
212+
'weeklyRoundup': true,
213+
},
214+
),
215+
);
216+
log.info('Reverted remote_configs document to legacy structure.');
217+
218+
log.info('Migration UnifyInterestsAndRemoteConfig.down completed.');
219+
}
220+
221+
/// Generates a stable, sorted key from interest criteria to identify
222+
/// duplicates.
223+
String _generateCriteriaKey(InterestCriteria criteria) {
224+
final topics = criteria.topics.map((t) => t.id).toList()..sort();
225+
final sources = criteria.sources.map((s) => s.id).toList()..sort();
226+
final countries = criteria.countries.map((c) => c.id).toList()..sort();
227+
return 't:${topics.join(',')};s:${sources.join(',')};c:${countries.join(',')}';
228+
}
229+
}

lib/src/database/migrations/all_migrations.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/database/migrat
66
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251103073226_remove_local_ad_platform.dart';
77
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251107000000_add_is_breaking_to_headlines.dart';
88
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251108103300_add_push_notification_config_to_remote_config.dart';
9+
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20251111000000_unify_interests_and_remote_config.dart';
910
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart'
1011
show DatabaseMigrationService;
1112

@@ -22,4 +23,5 @@ final List<Migration> allMigrations = [
2223
RemoveLocalAdPlatform(),
2324
AddIsBreakingToHeadlines(),
2425
AddPushNotificationConfigToRemoteConfig(),
26+
UnifyInterestsAndRemoteConfig(),
2527
];

lib/src/rbac/permissions.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,17 @@ abstract class Permissions {
9595
'push_notification_device.create_owned';
9696
static const String pushNotificationDeviceDeleteOwned =
9797
'push_notification_device.delete_owned';
98+
99+
// In-App Notification Permissions (User-owned)
100+
/// Allows reading the user's own in-app notifications.
101+
static const String inAppNotificationReadOwned =
102+
'in_app_notification.read_owned';
103+
104+
/// Allows updating the user's own in-app notifications (e.g., marking as read).
105+
static const String inAppNotificationUpdateOwned =
106+
'in_app_notification.update_owned';
107+
108+
/// Allows deleting the user's own in-app notifications.
109+
static const String inAppNotificationDeleteOwned =
110+
'in_app_notification.delete_owned';
98111
}

lib/src/rbac/role_permissions.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ final Set<String> _appGuestUserPermissions = {
2525
// notifications.
2626
Permissions.pushNotificationDeviceCreateOwned,
2727
Permissions.pushNotificationDeviceDeleteOwned,
28+
// Allow all app users to manage their own in-app notifications.
29+
Permissions.inAppNotificationReadOwned,
30+
Permissions.inAppNotificationUpdateOwned,
31+
Permissions.inAppNotificationDeleteOwned,
2832
};
2933

3034
final Set<String> _appStandardUserPermissions = {

0 commit comments

Comments
 (0)