Skip to content

Commit bf23c06

Browse files
authored
Merge pull request #12 from headlines-toolkit/fix_latest_refactor_bug
Fix latest refactor bug
2 parents 9fabaae + b81371a commit bf23c06

File tree

9 files changed

+681
-343
lines changed

9 files changed

+681
-343
lines changed

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,13 +99,14 @@ for more details.
9999
CONFLICT DO NOTHING` to avoid overwriting existing tables or data.
100100
101101
102-
**Note on Web Client Integration (CORS):**
103-
To allow web applications (like the HT Dashboard) to connect to this API,
104-
the `CORS_ALLOWED_ORIGIN` environment variable must be set to the
105-
specific origin of your web application (e.g., `https://your-dashboard.com`).
106-
For local development, if this variable is not set, the API defaults to
107-
allowing `http://localhost:3000` and issues a console warning. See the
108-
`routes/api/v1/_middleware.dart` file for the exact implementation details.
102+
**Note on Web Client Integration (CORS):** To allow web applications (like
103+
the HT Dashboard) to connect to this API in production, the
104+
`CORS_ALLOWED_ORIGIN` environment variable must be set to the specific
105+
origin of your web application (e.g., `https://your-dashboard.com`).
106+
107+
For local development, the API automatically allows any request
108+
originating from `localhost` on any port, so you do not need to set this
109+
variable.
109110
110111
## ✅ Testing
111112

lib/src/config/app_dependencies.dart

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
4+
import 'package:ht_api/src/config/database_connection.dart';
5+
import 'package:ht_api/src/rbac/permission_service.dart';
6+
import 'package:ht_api/src/services/auth_service.dart';
7+
import 'package:ht_api/src/services/auth_token_service.dart';
8+
import 'package:ht_api/src/services/dashboard_summary_service.dart';
9+
import 'package:ht_api/src/services/database_seeding_service.dart';
10+
import 'package:ht_api/src/services/default_user_preference_limit_service.dart';
11+
import 'package:ht_api/src/services/jwt_auth_token_service.dart';
12+
import 'package:ht_api/src/services/token_blacklist_service.dart';
13+
import 'package:ht_api/src/services/user_preference_limit_service.dart';
14+
import 'package:ht_api/src/services/verification_code_storage_service.dart';
15+
import 'package:ht_data_client/ht_data_client.dart';
16+
import 'package:ht_data_postgres/ht_data_postgres.dart';
17+
import 'package:ht_data_repository/ht_data_repository.dart';
18+
import 'package:ht_email_inmemory/ht_email_inmemory.dart';
19+
import 'package:ht_email_repository/ht_email_repository.dart';
20+
import 'package:ht_shared/ht_shared.dart';
21+
import 'package:logging/logging.dart';
22+
import 'package:postgres/postgres.dart';
23+
import 'package:uuid/uuid.dart';
24+
25+
/// A singleton class to manage all application dependencies.
26+
///
27+
/// This class follows a lazy initialization pattern. Dependencies are created
28+
/// only when the `init()` method is first called, typically triggered by the
29+
/// first incoming request. A `Completer` ensures that subsequent requests
30+
/// await the completion of the initial setup.
31+
class AppDependencies {
32+
AppDependencies._();
33+
34+
/// The single, global instance of the [AppDependencies].
35+
static final instance = AppDependencies._();
36+
37+
final _log = Logger('AppDependencies');
38+
final _completer = Completer<void>();
39+
40+
// --- Repositories ---
41+
late final HtDataRepository<Headline> headlineRepository;
42+
late final HtDataRepository<Category> categoryRepository;
43+
late final HtDataRepository<Source> sourceRepository;
44+
late final HtDataRepository<Country> countryRepository;
45+
late final HtDataRepository<User> userRepository;
46+
late final HtDataRepository<UserAppSettings> userAppSettingsRepository;
47+
late final HtDataRepository<UserContentPreferences>
48+
userContentPreferencesRepository;
49+
late final HtDataRepository<AppConfig> appConfigRepository;
50+
51+
// --- Services ---
52+
late final HtEmailRepository emailRepository;
53+
late final TokenBlacklistService tokenBlacklistService;
54+
late final AuthTokenService authTokenService;
55+
late final VerificationCodeStorageService verificationCodeStorageService;
56+
late final AuthService authService;
57+
late final DashboardSummaryService dashboardSummaryService;
58+
late final PermissionService permissionService;
59+
late final UserPreferenceLimitService userPreferenceLimitService;
60+
61+
/// Initializes all application dependencies.
62+
///
63+
/// This method is idempotent. It performs the full initialization only on
64+
/// the first call. Subsequent calls will await the result of the first one.
65+
Future<void> init() {
66+
if (_completer.isCompleted) {
67+
_log.fine('Dependencies already initializing/initialized.');
68+
return _completer.future;
69+
}
70+
71+
_log.info('Initializing application dependencies...');
72+
_init()
73+
.then((_) {
74+
_log.info('Application dependencies initialized successfully.');
75+
_completer.complete();
76+
})
77+
.catchError((Object e, StackTrace s) {
78+
_log.severe('Failed to initialize application dependencies.', e, s);
79+
_completer.completeError(e, s);
80+
});
81+
82+
return _completer.future;
83+
}
84+
85+
Future<void> _init() async {
86+
// 1. Establish Database Connection.
87+
await DatabaseConnectionManager.instance.init();
88+
final connection = await DatabaseConnectionManager.instance.connection;
89+
90+
// 2. Run Database Seeding.
91+
final seedingService = DatabaseSeedingService(
92+
connection: connection,
93+
log: _log,
94+
);
95+
await seedingService.createTables();
96+
await seedingService.seedGlobalFixtureData();
97+
await seedingService.seedInitialAdminAndConfig();
98+
99+
// 3. Initialize Repositories.
100+
headlineRepository = _createRepository(
101+
connection,
102+
'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+
(h) => h.toJson(), // toJson already handles DateTime correctly
119+
);
120+
categoryRepository = _createRepository(
121+
connection,
122+
'categories',
123+
(json) {
124+
if (json['created_at'] is DateTime) {
125+
json['created_at'] =
126+
(json['created_at'] as DateTime).toIso8601String();
127+
}
128+
if (json['updated_at'] is DateTime) {
129+
json['updated_at'] =
130+
(json['updated_at'] as DateTime).toIso8601String();
131+
}
132+
return Category.fromJson(json);
133+
},
134+
(c) => c.toJson(),
135+
);
136+
sourceRepository = _createRepository(
137+
connection,
138+
'sources',
139+
(json) {
140+
if (json['created_at'] is DateTime) {
141+
json['created_at'] =
142+
(json['created_at'] as DateTime).toIso8601String();
143+
}
144+
if (json['updated_at'] is DateTime) {
145+
json['updated_at'] =
146+
(json['updated_at'] as DateTime).toIso8601String();
147+
}
148+
return Source.fromJson(json);
149+
},
150+
(s) => s.toJson(),
151+
);
152+
countryRepository = _createRepository(
153+
connection,
154+
'countries',
155+
(json) {
156+
if (json['created_at'] is DateTime) {
157+
json['created_at'] =
158+
(json['created_at'] as DateTime).toIso8601String();
159+
}
160+
if (json['updated_at'] is DateTime) {
161+
json['updated_at'] =
162+
(json['updated_at'] as DateTime).toIso8601String();
163+
}
164+
return Country.fromJson(json);
165+
},
166+
(c) => c.toJson(),
167+
);
168+
userRepository = _createRepository(
169+
connection,
170+
'users',
171+
(json) {
172+
// The postgres driver returns DateTime objects, but the model's
173+
// fromJson expects ISO 8601 strings. We must convert them first.
174+
if (json['created_at'] is DateTime) {
175+
json['created_at'] = (json['created_at'] as DateTime).toIso8601String();
176+
}
177+
if (json['last_engagement_shown_at'] is DateTime) {
178+
json['last_engagement_shown_at'] =
179+
(json['last_engagement_shown_at'] as DateTime).toIso8601String();
180+
}
181+
return User.fromJson(json);
182+
},
183+
(user) {
184+
// The `roles` field is a List<String>, but the database expects a
185+
// JSONB array. We must explicitly encode it.
186+
final json = user.toJson();
187+
json['roles'] = jsonEncode(json['roles']);
188+
return json;
189+
},
190+
);
191+
userAppSettingsRepository = _createRepository(
192+
connection,
193+
'user_app_settings',
194+
(json) {
195+
// The DB has created_at/updated_at, but the model doesn't.
196+
// Remove them before deserialization to avoid CheckedFromJsonException.
197+
json.remove('created_at');
198+
json.remove('updated_at');
199+
return UserAppSettings.fromJson(json);
200+
},
201+
(settings) {
202+
final json = settings.toJson();
203+
// These fields are complex objects and must be JSON encoded for the DB.
204+
json['display_settings'] = jsonEncode(json['display_settings']);
205+
json['feed_preferences'] = jsonEncode(json['feed_preferences']);
206+
json['engagement_shown_counts'] =
207+
jsonEncode(json['engagement_shown_counts']);
208+
json['engagement_last_shown_timestamps'] =
209+
jsonEncode(json['engagement_last_shown_timestamps']);
210+
return json;
211+
},
212+
);
213+
userContentPreferencesRepository = _createRepository(
214+
connection,
215+
'user_content_preferences',
216+
(json) {
217+
// The postgres driver returns DateTime objects, but the model's
218+
// fromJson expects ISO 8601 strings. We must convert them first.
219+
if (json['created_at'] is DateTime) {
220+
json['created_at'] =
221+
(json['created_at'] as DateTime).toIso8601String();
222+
}
223+
if (json['updated_at'] is DateTime) {
224+
json['updated_at'] =
225+
(json['updated_at'] as DateTime).toIso8601String();
226+
}
227+
return UserContentPreferences.fromJson(json);
228+
},
229+
(preferences) {
230+
final json = preferences.toJson();
231+
json['followed_categories'] = jsonEncode(json['followed_categories']);
232+
json['followed_sources'] = jsonEncode(json['followed_sources']);
233+
json['followed_countries'] = jsonEncode(json['followed_countries']);
234+
json['saved_headlines'] = jsonEncode(json['saved_headlines']);
235+
return json;
236+
},
237+
);
238+
appConfigRepository = _createRepository(
239+
connection,
240+
'app_config',
241+
(json) {
242+
if (json['created_at'] is DateTime) {
243+
json['created_at'] =
244+
(json['created_at'] as DateTime).toIso8601String();
245+
}
246+
if (json['updated_at'] is DateTime) {
247+
json['updated_at'] =
248+
(json['updated_at'] as DateTime).toIso8601String();
249+
}
250+
return AppConfig.fromJson(json);
251+
},
252+
(c) => c.toJson(),
253+
);
254+
255+
// 4. Initialize Services.
256+
emailRepository = const HtEmailRepository(
257+
emailClient: HtEmailInMemoryClient(),
258+
);
259+
tokenBlacklistService = InMemoryTokenBlacklistService();
260+
authTokenService = JwtAuthTokenService(
261+
userRepository: userRepository,
262+
blacklistService: tokenBlacklistService,
263+
uuidGenerator: const Uuid(),
264+
);
265+
verificationCodeStorageService = InMemoryVerificationCodeStorageService();
266+
authService = AuthService(
267+
userRepository: userRepository,
268+
authTokenService: authTokenService,
269+
verificationCodeStorageService: verificationCodeStorageService,
270+
emailRepository: emailRepository,
271+
userAppSettingsRepository: userAppSettingsRepository,
272+
userContentPreferencesRepository: userContentPreferencesRepository,
273+
uuidGenerator: const Uuid(),
274+
);
275+
dashboardSummaryService = DashboardSummaryService(
276+
headlineRepository: headlineRepository,
277+
categoryRepository: categoryRepository,
278+
sourceRepository: sourceRepository,
279+
);
280+
permissionService = const PermissionService();
281+
userPreferenceLimitService = DefaultUserPreferenceLimitService(
282+
appConfigRepository: appConfigRepository,
283+
);
284+
}
285+
286+
HtDataRepository<T> _createRepository<T>(
287+
Connection connection,
288+
String tableName,
289+
FromJson<T> fromJson,
290+
ToJson<T> toJson,
291+
) {
292+
return HtDataRepository<T>(
293+
dataClient: HtDataPostgresClient<T>(
294+
connection: connection,
295+
tableName: tableName,
296+
fromJson: fromJson,
297+
toJson: toJson,
298+
log: _log,
299+
),
300+
);
301+
}
302+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import 'dart:async';
2+
3+
import 'package:ht_api/src/config/environment_config.dart';
4+
import 'package:logging/logging.dart';
5+
import 'package:postgres/postgres.dart';
6+
7+
/// A singleton class to manage a single, shared PostgreSQL database connection.
8+
///
9+
/// This pattern ensures that the application establishes a connection to the
10+
/// database only once and reuses it for all subsequent operations, which is
11+
/// crucial for performance and resource management.
12+
class DatabaseConnectionManager {
13+
// Private constructor for the singleton pattern.
14+
DatabaseConnectionManager._();
15+
16+
/// The single, global instance of the [DatabaseConnectionManager].
17+
static final instance = DatabaseConnectionManager._();
18+
19+
final _log = Logger('DatabaseConnectionManager');
20+
21+
// A completer to signal when the database connection is established.
22+
final _completer = Completer<Connection>();
23+
24+
/// Returns a future that completes with the established database connection.
25+
///
26+
/// If the connection has not been initialized yet, it calls `init()` to
27+
/// establish it. Subsequent calls will return the same connection future.
28+
Future<Connection> get connection => _completer.future;
29+
30+
/// Initializes the database connection.
31+
///
32+
/// This method is idempotent. It parses the database URL from the
33+
/// environment, opens a connection to the PostgreSQL server, and completes
34+
/// the `_completer` with the connection. It only performs the connection
35+
/// logic on the very first call.
36+
Future<void> init() async {
37+
if (_completer.isCompleted) {
38+
_log.fine('Database connection already initializing/initialized.');
39+
return;
40+
}
41+
42+
_log.info('Initializing database connection...');
43+
final dbUri = Uri.parse(EnvironmentConfig.databaseUrl);
44+
String? username;
45+
String? password;
46+
if (dbUri.userInfo.isNotEmpty) {
47+
final parts = dbUri.userInfo.split(':');
48+
username = Uri.decodeComponent(parts.first);
49+
if (parts.length > 1) {
50+
password = Uri.decodeComponent(parts.last);
51+
}
52+
}
53+
54+
final connection = await Connection.open(
55+
Endpoint(
56+
host: dbUri.host,
57+
port: dbUri.port,
58+
database: dbUri.path.substring(1),
59+
username: username,
60+
password: password,
61+
),
62+
settings: const ConnectionSettings(sslMode: SslMode.require),
63+
);
64+
_log.info('Database connection established successfully.');
65+
_completer.complete(connection);
66+
}
67+
}

0 commit comments

Comments
 (0)