Skip to content

Commit 5d559e2

Browse files
authored
Merge pull request #14 from headlines-toolkit/refactor_sync_with_new_models_api
Refactor sync with new models api
2 parents 5e9f921 + 228396e commit 5d559e2

25 files changed

+887
-785
lines changed

README.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,37 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard).
1717
## ✨ Key Capabilities
1818

1919
* 🔒 **Flexible & Secure Authentication:** Provide seamless user access with
20-
a unified system supporting passwordless sign-in, anonymous guest
21-
accounts, and a secure, context-aware login flow for privileged dashboard
22-
users (e.g., 'admin', 'publisher').
20+
a unified system supporting passwordless email sign-in, anonymous guest
21+
accounts, and a secure, role-aware login flow for privileged dashboard
22+
users.
2323

24-
* ⚡️ **Flexible Role-Based Access Control (RBAC):** Implement granular
25-
permissions with a flexible, multi-role system. Assign multiple roles to
26-
users (e.g., 'admin', 'publisher', 'premium_user') to precisely control
27-
access to different API features and data management capabilities.
24+
* ⚡️ **Granular Role-Based Access Control (RBAC):** Implement precise
25+
permissions with a dual-role system (`appRole` for application features,
26+
`dashboardRole` for admin functions) to control access to API features
27+
and data management capabilities.
2828

2929
* ⚙️ **Synchronized App Settings:** Ensure a consistent and personalized user
3030
experience across devices by effortlessly syncing application preferences
3131
like theme, language, font styles, and more.
3232

3333
* 👤 **Personalized User Preferences:** Enable richer user interactions by
34-
managing and syncing user-specific data such as saved headlines, followed sources, or other personalized content tailored to individual users.
34+
managing and syncing user-specific data such as saved headlines, followed
35+
sources, and followed topics tailored to individual users.
3536

3637
* 💾 **Robust Data Management:** Securely manage core news data (headlines,
37-
categories, sources) through a well-structured API that supports flexible
38+
topics, sources) through a well-structured API that supports flexible
39+
querying and sorting for dynamic content presentation.
40+
41+
* 🌐 **Dynamic Remote Configuration:** Centrally manage application
42+
behavior—including ad frequency, feature flags, and maintenance status—without
43+
requiring a client-side update.
44+
45+
* 💾 **Robust Data Management:** Securely manage core news data (headlines,
46+
topics, sources) through a well-structured API that supports flexible
3847
querying and sorting for dynamic content presentation.
3948

4049
* 📊 **Dynamic Dashboard Summary:** Access real-time, aggregated metrics on
41-
key data points like total headlines, categories, and sources, providing
50+
key data points like total headlines, topics, and sources, providing
4251
an at-a-glance overview for administrative dashboards.
4352

4453
* 🔧 **Solid Technical Foundation:** Built with Dart and the high-performance

lib/src/config/app_dependencies.dart

Lines changed: 100 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,54 @@ class AppDependencies {
3838
final _completer = Completer<void>();
3939

4040
// --- Repositories ---
41+
/// A repository for managing [Headline] data.
4142
late final HtDataRepository<Headline> headlineRepository;
42-
late final HtDataRepository<Category> categoryRepository;
43+
44+
/// A repository for managing [Topic] data.
45+
late final HtDataRepository<Topic> topicRepository;
46+
47+
/// A repository for managing [Source] data.
4348
late final HtDataRepository<Source> sourceRepository;
49+
50+
/// A repository for managing [Country] data.
4451
late final HtDataRepository<Country> countryRepository;
52+
53+
/// A repository for managing [User] data.
4554
late final HtDataRepository<User> userRepository;
55+
56+
/// A repository for managing [UserAppSettings] data.
4657
late final HtDataRepository<UserAppSettings> userAppSettingsRepository;
58+
59+
/// A repository for managing [UserContentPreferences] data.
4760
late final HtDataRepository<UserContentPreferences>
4861
userContentPreferencesRepository;
49-
late final HtDataRepository<AppConfig> appConfigRepository;
62+
63+
/// A repository for managing the global [RemoteConfig] data.
64+
late final HtDataRepository<RemoteConfig> remoteConfigRepository;
5065

5166
// --- Services ---
67+
/// A service for sending emails.
5268
late final HtEmailRepository emailRepository;
69+
70+
/// A service for managing a blacklist of invalidated authentication tokens.
5371
late final TokenBlacklistService tokenBlacklistService;
72+
73+
/// A service for generating and validating authentication tokens.
5474
late final AuthTokenService authTokenService;
75+
76+
/// A service for storing and validating one-time verification codes.
5577
late final VerificationCodeStorageService verificationCodeStorageService;
78+
79+
/// A service that orchestrates authentication logic.
5680
late final AuthService authService;
81+
82+
/// A service for calculating and providing a summary for the dashboard.
5783
late final DashboardSummaryService dashboardSummaryService;
84+
85+
/// A service for checking user permissions.
5886
late final PermissionService permissionService;
87+
88+
/// A service for enforcing limits on user content preferences.
5989
late final UserPreferenceLimitService userPreferenceLimitService;
6090

6191
/// Initializes all application dependencies.
@@ -100,189 +130,107 @@ class AppDependencies {
100130
headlineRepository = _createRepository(
101131
connection,
102132
'headlines',
103-
(json) {
104-
if (json['created_at'] is DateTime) {
105-
json['created_at'] =
106-
(json['created_at'] as DateTime).toIso8601String();
107-
}
108-
if (json['updated_at'] is DateTime) {
109-
json['updated_at'] =
110-
(json['updated_at'] as DateTime).toIso8601String();
111-
}
112-
if (json['published_at'] is DateTime) {
113-
json['published_at'] =
114-
(json['published_at'] as DateTime).toIso8601String();
115-
}
116-
return Headline.fromJson(json);
117-
},
118-
(headline) {
119-
final json = headline.toJson();
120-
// The database expects source_id and category_id, not nested objects.
121-
// We extract the IDs and remove the original objects to match the
122-
// schema.
123-
if (headline.source != null) {
124-
json['source_id'] = headline.source!.id;
125-
}
126-
if (headline.category != null) {
127-
json['category_id'] = headline.category!.id;
128-
}
129-
json.remove('source');
130-
json.remove('category');
131-
return json;
132-
},
133+
// The HtDataPostgresClient returns DateTime objects from TIMESTAMPTZ
134+
// columns. The Headline.fromJson factory expects ISO 8601 strings.
135+
// This handler converts them before deserialization.
136+
(json) => Headline.fromJson(_convertTimestampsToString(json)),
137+
(headline) => headline.toJson()
138+
..['source_id'] = headline.source.id
139+
..['topic_id'] = headline.topic.id
140+
..['event_country_id'] = headline.eventCountry.id
141+
..remove('source')
142+
..remove('topic')
143+
..remove('eventCountry'),
133144
);
134-
categoryRepository = _createRepository(
145+
topicRepository = _createRepository(
135146
connection,
136-
'categories',
137-
(json) {
138-
if (json['created_at'] is DateTime) {
139-
json['created_at'] =
140-
(json['created_at'] as DateTime).toIso8601String();
141-
}
142-
if (json['updated_at'] is DateTime) {
143-
json['updated_at'] =
144-
(json['updated_at'] as DateTime).toIso8601String();
145-
}
146-
return Category.fromJson(json);
147-
},
148-
(c) => c.toJson(),
147+
'topics',
148+
(json) => Topic.fromJson(_convertTimestampsToString(json)),
149+
(topic) => topic.toJson(),
149150
);
150151
sourceRepository = _createRepository(
151152
connection,
152153
'sources',
153-
(json) {
154-
if (json['created_at'] is DateTime) {
155-
json['created_at'] =
156-
(json['created_at'] as DateTime).toIso8601String();
157-
}
158-
if (json['updated_at'] is DateTime) {
159-
json['updated_at'] =
160-
(json['updated_at'] as DateTime).toIso8601String();
161-
}
162-
return Source.fromJson(json);
163-
},
164-
(source) {
165-
final json = source.toJson();
166-
// The database expects headquarters_country_id, not a nested object.
167-
// We extract the ID and remove the original object to match the
168-
// schema.
169-
json['headquarters_country_id'] = source.headquarters?.id;
170-
json.remove('headquarters');
171-
return json;
172-
},
154+
(json) => Source.fromJson(_convertTimestampsToString(json)),
155+
(source) => source.toJson()
156+
..['headquarters_country_id'] = source.headquarters.id
157+
..remove('headquarters'),
173158
);
174159
countryRepository = _createRepository(
175160
connection,
176161
'countries',
177-
(json) {
178-
if (json['created_at'] is DateTime) {
179-
json['created_at'] =
180-
(json['created_at'] as DateTime).toIso8601String();
181-
}
182-
if (json['updated_at'] is DateTime) {
183-
json['updated_at'] =
184-
(json['updated_at'] as DateTime).toIso8601String();
185-
}
186-
return Country.fromJson(json);
187-
},
188-
(c) => c.toJson(),
162+
(json) => Country.fromJson(_convertTimestampsToString(json)),
163+
(country) => country.toJson(),
189164
);
190165
userRepository = _createRepository(
191166
connection,
192167
'users',
193-
(json) {
194-
// The postgres driver returns DateTime objects, but the model's
195-
// fromJson expects ISO 8601 strings. We must convert them first.
196-
if (json['created_at'] is DateTime) {
197-
json['created_at'] = (json['created_at'] as DateTime).toIso8601String();
198-
}
199-
if (json['last_engagement_shown_at'] is DateTime) {
200-
json['last_engagement_shown_at'] =
201-
(json['last_engagement_shown_at'] as DateTime).toIso8601String();
202-
}
203-
return User.fromJson(json);
204-
},
168+
(json) => User.fromJson(_convertTimestampsToString(json)),
205169
(user) {
206-
// The `roles` field is a List<String>, but the database expects a
207-
// JSONB array. We must explicitly encode it.
208170
final json = user.toJson();
209-
json['roles'] = jsonEncode(json['roles']);
171+
// Convert enums to their string names for the database.
172+
json['app_role'] = user.appRole.name;
173+
json['dashboard_role'] = user.dashboardRole.name;
174+
// The `feed_action_status` map must be JSON encoded for the JSONB column.
175+
json['feed_action_status'] = jsonEncode(json['feed_action_status']);
210176
return json;
211177
},
212178
);
213179
userAppSettingsRepository = _createRepository(
214180
connection,
215181
'user_app_settings',
216-
(json) {
217-
// The DB has created_at/updated_at, but the model doesn't.
218-
// Remove them before deserialization to avoid CheckedFromJsonException.
219-
json.remove('created_at');
220-
json.remove('updated_at');
221-
return UserAppSettings.fromJson(json);
222-
},
182+
UserAppSettings.fromJson,
223183
(settings) {
224184
final json = settings.toJson();
225185
// These fields are complex objects and must be JSON encoded for the DB.
226186
json['display_settings'] = jsonEncode(json['display_settings']);
227187
json['feed_preferences'] = jsonEncode(json['feed_preferences']);
228-
json['engagement_shown_counts'] =
229-
jsonEncode(json['engagement_shown_counts']);
230-
json['engagement_last_shown_timestamps'] =
231-
jsonEncode(json['engagement_last_shown_timestamps']);
232188
return json;
233189
},
234190
);
235191
userContentPreferencesRepository = _createRepository(
236192
connection,
237193
'user_content_preferences',
238-
(json) {
239-
// The postgres driver returns DateTime objects, but the model's
240-
// fromJson expects ISO 8601 strings. We must convert them first.
241-
if (json['created_at'] is DateTime) {
242-
json['created_at'] =
243-
(json['created_at'] as DateTime).toIso8601String();
244-
}
245-
if (json['updated_at'] is DateTime) {
246-
json['updated_at'] =
247-
(json['updated_at'] as DateTime).toIso8601String();
248-
}
249-
return UserContentPreferences.fromJson(json);
250-
},
194+
UserContentPreferences.fromJson,
251195
(preferences) {
252196
final json = preferences.toJson();
253-
json['followed_categories'] = jsonEncode(json['followed_categories']);
197+
// These fields are lists of complex objects and must be JSON encoded.
198+
json['followed_topics'] = jsonEncode(json['followed_topics']);
254199
json['followed_sources'] = jsonEncode(json['followed_sources']);
255200
json['followed_countries'] = jsonEncode(json['followed_countries']);
256201
json['saved_headlines'] = jsonEncode(json['saved_headlines']);
257202
return json;
258203
},
259204
);
260-
appConfigRepository = _createRepository(
205+
remoteConfigRepository = _createRepository(
261206
connection,
262-
'app_config',
263-
(json) {
264-
if (json['created_at'] is DateTime) {
265-
json['created_at'] =
266-
(json['created_at'] as DateTime).toIso8601String();
267-
}
268-
if (json['updated_at'] is DateTime) {
269-
json['updated_at'] =
270-
(json['updated_at'] as DateTime).toIso8601String();
271-
}
272-
return AppConfig.fromJson(json);
207+
'remote_config',
208+
(json) => RemoteConfig.fromJson(_convertTimestampsToString(json)),
209+
(config) {
210+
final json = config.toJson();
211+
// All nested config objects must be JSON encoded for JSONB columns.
212+
json['user_preference_limits'] = jsonEncode(
213+
json['user_preference_limits'],
214+
);
215+
json['ad_config'] = jsonEncode(json['ad_config']);
216+
json['account_action_config'] = jsonEncode(
217+
json['account_action_config'],
218+
);
219+
json['app_status'] = jsonEncode(json['app_status']);
220+
return json;
273221
},
274-
(c) => c.toJson(),
275222
);
276223

277224
// 4. Initialize Services.
278225
emailRepository = const HtEmailRepository(
279226
emailClient: HtEmailInMemoryClient(),
280227
);
281-
tokenBlacklistService = InMemoryTokenBlacklistService();
228+
tokenBlacklistService = InMemoryTokenBlacklistService(log: _log);
282229
authTokenService = JwtAuthTokenService(
283230
userRepository: userRepository,
284231
blacklistService: tokenBlacklistService,
285232
uuidGenerator: const Uuid(),
233+
log: _log,
286234
);
287235
verificationCodeStorageService = InMemoryVerificationCodeStorageService();
288236
authService = AuthService(
@@ -293,15 +241,17 @@ class AppDependencies {
293241
userAppSettingsRepository: userAppSettingsRepository,
294242
userContentPreferencesRepository: userContentPreferencesRepository,
295243
uuidGenerator: const Uuid(),
244+
log: _log,
296245
);
297246
dashboardSummaryService = DashboardSummaryService(
298247
headlineRepository: headlineRepository,
299-
categoryRepository: categoryRepository,
248+
topicRepository: topicRepository,
300249
sourceRepository: sourceRepository,
301250
);
302251
permissionService = const PermissionService();
303252
userPreferenceLimitService = DefaultUserPreferenceLimitService(
304-
appConfigRepository: appConfigRepository,
253+
remoteConfigRepository: remoteConfigRepository,
254+
log: _log,
305255
);
306256
}
307257

@@ -321,4 +271,20 @@ class AppDependencies {
321271
),
322272
);
323273
}
274+
275+
/// Converts DateTime values in a JSON map to ISO 8601 strings.
276+
///
277+
/// The postgres driver returns DateTime objects for TIMESTAMPTZ columns,
278+
/// but our models' `fromJson` factories expect ISO 8601 strings. This
279+
/// utility function performs the conversion for known timestamp fields.
280+
Map<String, dynamic> _convertTimestampsToString(Map<String, dynamic> json) {
281+
const timestampKeys = {'created_at', 'updated_at'};
282+
final newJson = Map<String, dynamic>.from(json);
283+
for (final key in timestampKeys) {
284+
if (newJson[key] is DateTime) {
285+
newJson[key] = (newJson[key] as DateTime).toIso8601String();
286+
}
287+
}
288+
return newJson;
289+
}
324290
}

0 commit comments

Comments
 (0)