Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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)

AltStyle によって変換されたページ (->オリジナル) /