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

Feature document database migration #15

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
fulleni merged 23 commits into main from feature_document_database_migration
Jul 12, 2025
Merged
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
49d1e91
feat(auth): create ownership check middleware
fulleni Jul 11, 2025
ea709e9
feat(auth): apply ownership check middleware to item routes
fulleni Jul 11, 2025
cc9d32f
refactor(api): adapt GET /data to use native document query
fulleni Jul 11, 2025
34a0029
refactor(api): adapt GET /data to use native document query
fulleni Jul 11, 2025
c065be4
feat(db): replace postgres seeding service with mongodb
fulleni Jul 11, 2025
fe51a1e
feat(db): replace postgres seeding service with mongodb
fulleni Jul 11, 2025
1bca9ab
refactor(deps): remove postgres dependencies
fulleni Jul 11, 2025
bfb3e5f
feat(db): migrate dependency injection to mongodb
fulleni Jul 11, 2025
bf6f59d
docs: update readme for mongodb migration
fulleni Jul 11, 2025
c6877bc
refactor(auth): use readAll with filter in AuthService
fulleni Jul 11, 2025
38853ea
docs: update .env.example for mongodb connection string
fulleni Jul 11, 2025
b07d6d2
fix(config): make .env file loading robust
fulleni Jul 11, 2025
7b24b53
/// issues where the execution context's working directory is not the
fulleni Jul 11, 2025
20a92d2
fix(config): correct directory traversal in .env search
fulleni Jul 11, 2025
96912c2
feat(config): enhance .env loading logs
fulleni Jul 11, 2025
e7d0d8d
fix(config): prevent state corruption on failed dependency init
fulleni Jul 11, 2025
d1ac01a
fix(config): make dependency init robust against all throwables
fulleni Jul 11, 2025
6e84bcf
chore
fulleni Jul 11, 2025
4ce3cc5
fix(api): make database seeding idempotent and preserve IDs
fulleni Jul 12, 2025
5207475
fix(api): ensure all user data is deleted on account deletion
fulleni Jul 12, 2025
dbae817
refactor(api): use repository count method for dashboard summary
fulleni Jul 12, 2025
c49d2c4
fix(api): improve error handling for invalid JSON body
fulleni Jul 12, 2025
b2b4363
fix(api): correct admin data scoping in generic data handlers
fulleni Jul 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(db): migrate dependency injection to mongodb
- Rewrites `AppDependencies` to use `MongoDbConnectionManager` instead of
 the PostgreSQL equivalent.
- Initializes the MongoDB connection and runs the new `DatabaseSeedingService`.
- Instantiates `HtDataMongodb` clients for all data models.
- Wires up all `HtDataRepository` instances and application services to use
 the new MongoDB-backed data layer.
- This completes the core dependency injection part of the migration.
  • Loading branch information
fulleni committed Jul 11, 2025
commit bfb3e5f953bf495d36a4cab78cf58ddbd7cb27ae
319 changes: 116 additions & 203 deletions lib/src/config/app_dependencies.dart
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import 'dart:async';
import 'dart:convert';

import 'package:ht_api/src/config/database_connection.dart';
import 'package:ht_api/src/config/environment_config.dart';
import 'package:ht_api/src/rbac/permission_service.dart';
import 'package:ht_api/src/services/auth_service.dart';
import 'package:ht_api/src/services/auth_token_service.dart';
Expand All @@ -12,225 +9,162 @@ import 'package:ht_api/src/services/jwt_auth_token_service.dart';
import 'package:ht_api/src/services/token_blacklist_service.dart';
import 'package:ht_api/src/services/user_preference_limit_service.dart';
import 'package:ht_api/src/services/verification_code_storage_service.dart';
import 'package:ht_data_client/ht_data_client.dart';
import 'package:ht_data_postgres/ht_data_postgres.dart';
import 'package:ht_data_mongodb/ht_data_mongodb.dart';
import 'package:ht_data_repository/ht_data_repository.dart';
import 'package:ht_email_inmemory/ht_email_inmemory.dart';
import 'package:ht_email_repository/ht_email_repository.dart';
import 'package:ht_shared/ht_shared.dart';
import 'package:logging/logging.dart';
import 'package:postgres/postgres.dart';
import 'package:uuid/uuid.dart';

/// A singleton class to manage all application dependencies.
///
/// This class follows a lazy initialization pattern. Dependencies are created
/// only when the `init()` method is first called, typically triggered by the
/// first incoming request. A `Completer` ensures that subsequent requests
/// await the completion of the initial setup.
/// {@template app_dependencies}
/// A singleton class responsible for initializing and providing all application
/// dependencies, such as database connections, repositories, and services.
/// {@endtemplate}
class AppDependencies {
/// Private constructor for the singleton pattern.
AppDependencies._();

/// The single, global instance of the [AppDependencies].
static final instance = AppDependencies._();
/// The single, static instance of this class.
static final AppDependencies _instance = AppDependencies._();

/// Provides access to the singleton instance.
static AppDependencies get instance => _instance;

bool _isInitialized = false;
final _log = Logger('AppDependencies');
final _completer = Completer<void>();

// --- Repositories ---
/// A repository for managing [Headline] data.
late final HtDataRepository<Headline> headlineRepository;
// --- Late-initialized fields for all dependencies ---

/// A repository for managing [Topic] data.
late final HtDataRepository<Topic> topicRepository;
// Database
late final MongoDbConnectionManager _mongoDbConnectionManager;

/// A repository for managing [Source] data.
// Repositories
late final HtDataRepository<Headline> headlineRepository;
late final HtDataRepository<Topic> topicRepository;
late final HtDataRepository<Source> sourceRepository;

/// A repository for managing [Country] data.
late final HtDataRepository<Country> countryRepository;

/// A repository for managing [User] data.
late final HtDataRepository<User> userRepository;

/// A repository for managing [UserAppSettings] data.
late final HtDataRepository<UserAppSettings> userAppSettingsRepository;

/// A repository for managing [UserContentPreferences] data.
late final HtDataRepository<UserContentPreferences>
userContentPreferencesRepository;

/// A repository for managing the global [RemoteConfig] data.
userContentPreferencesRepository;
late final HtDataRepository<RemoteConfig> remoteConfigRepository;

// --- Services ---
/// A service for sending emails.
late final HtEmailRepository emailRepository;

/// A service for managing a blacklist of invalidated authentication tokens.
// Services
late final TokenBlacklistService tokenBlacklistService;

/// A service for generating and validating authentication tokens.
late final AuthTokenService authTokenService;

/// A service for storing and validating one-time verification codes.
late final VerificationCodeStorageService verificationCodeStorageService;

/// A service that orchestrates authentication logic.
late final AuthService authService;

/// A service for calculating and providing a summary for the dashboard.
late final DashboardSummaryService dashboardSummaryService;

/// A service for checking user permissions.
late final PermissionService permissionService;

/// A service for enforcing limits on user content preferences.
late final UserPreferenceLimitService userPreferenceLimitService;

/// Initializes all application dependencies.
///
/// This method is idempotent. It performs the full initialization only on
/// the first call. Subsequent calls will await the result of the first one.
Future<void> init() {
if (_completer.isCompleted) {
_log.fine('Dependencies already initializing/initialized.');
return _completer.future;
}
/// This method is idempotent; it will only run the initialization logic once.
Future<void> init() async {
if (_isInitialized) return;

_log.info('Initializing application dependencies...');
_init()
.then((_) {
_log.info('Application dependencies initialized successfully.');
_completer.complete();
})
.catchError((Object e, StackTrace s) {
_log.severe('Failed to initialize application dependencies.', e, s);
_completer.completeError(e, s);
});

return _completer.future;
}

Future<void> _init() async {
// 1. Establish Database Connection.
await DatabaseConnectionManager.instance.init();
final connection = await DatabaseConnectionManager.instance.connection;
// 1. Initialize Database Connection
_mongoDbConnectionManager = MongoDbConnectionManager();
await _mongoDbConnectionManager.init(EnvironmentConfig.databaseUrl);
_log.info('MongoDB connection established.');

// 2. Run Database Seeding.
// 2. Seed Database
final seedingService = DatabaseSeedingService(
connection: connection,
log: _log,
db: _mongoDbConnectionManager.db,
log: Logger('DatabaseSeedingService'),
);
await seedingService.createTables();
await seedingService.seedGlobalFixtureData();
await seedingService.seedInitialAdminAndConfig();

// 3. Initialize Repositories.
headlineRepository = _createRepository(
connection,
'headlines',
// The HtDataPostgresClient returns DateTime objects from TIMESTAMPTZ
// columns. The Headline.fromJson factory expects ISO 8601 strings.
// This handler converts them before deserialization.
(json) => Headline.fromJson(_convertTimestampsToString(json)),
(headline) => headline.toJson()
..['source_id'] = headline.source.id
..['topic_id'] = headline.topic.id
..['event_country_id'] = headline.eventCountry.id
..remove('source')
..remove('topic')
..remove('eventCountry'),
await seedingService.seedInitialData();
_log.info('Database seeding complete.');

// 3. Initialize Data Clients (MongoDB implementation)
final headlineClient = HtDataMongodb<Headline>(
connectionManager: _mongoDbConnectionManager,
modelName: 'headlines',
fromJson: Headline.fromJson,
toJson: (item) => item.toJson(),
logger: Logger('HtDataMongodb<Headline>'),
);
final topicClient = HtDataMongodb<Topic>(
connectionManager: _mongoDbConnectionManager,
modelName: 'topics',
fromJson: Topic.fromJson,
toJson: (item) => item.toJson(),
logger: Logger('HtDataMongodb<Topic>'),
);
topicRepository = _createRepository(
connection,
'topics',
(json) => Topic.fromJson(_convertTimestampsToString(json)),
(topic) => topic.toJson(),
final sourceClient = HtDataMongodb<Source>(
connectionManager: _mongoDbConnectionManager,
modelName: 'sources',
fromJson: Source.fromJson,
toJson: (item) => item.toJson(),
logger: Logger('HtDataMongodb<Source>'),
);
sourceRepository = _createRepository(
connection,
'sources',
(json) => Source.fromJson(_convertTimestampsToString(json)),
(source) => source.toJson()
..['headquarters_country_id'] = source.headquarters.id
..remove('headquarters'),
final countryClient = HtDataMongodb<Country>(
connectionManager: _mongoDbConnectionManager,
modelName: 'countries',
fromJson: Country.fromJson,
toJson: (item) => item.toJson(),
logger: Logger('HtDataMongodb<Country>'),
);
countryRepository = _createRepository(
connection,
'countries',
(json) => Country.fromJson(_convertTimestampsToString(json)),
(country) => country.toJson(),
final userClient = HtDataMongodb<User>(
connectionManager: _mongoDbConnectionManager,
modelName: 'users',
fromJson: User.fromJson,
toJson: (item) => item.toJson(),
logger: Logger('HtDataMongodb<User>'),
);
userRepository = _createRepository(
connection,
'users',
(json) => User.fromJson(_convertTimestampsToString(json)),
(user) {
final json = user.toJson();
// Convert enums to their string names for the database.
json['app_role'] = user.appRole.name;
json['dashboard_role'] = user.dashboardRole.name;
// The `feed_action_status` map must be JSON encoded for the JSONB column.
json['feed_action_status'] = jsonEncode(json['feed_action_status']);
return json;
},
final userAppSettingsClient = HtDataMongodb<UserAppSettings>(
connectionManager: _mongoDbConnectionManager,
modelName: 'user_app_settings',
fromJson: UserAppSettings.fromJson,
toJson: (item) => item.toJson(),
logger: Logger('HtDataMongodb<UserAppSettings>'),
);
userAppSettingsRepository = _createRepository(
connection,
'user_app_settings',
UserAppSettings.fromJson,
(settings) {
final json = settings.toJson();
// These fields are complex objects and must be JSON encoded for the DB.
json['display_settings'] = jsonEncode(json['display_settings']);
json['feed_preferences'] = jsonEncode(json['feed_preferences']);
return json;
},
final userContentPreferencesClient = HtDataMongodb<UserContentPreferences>(
connectionManager: _mongoDbConnectionManager,
modelName: 'user_content_preferences',
fromJson: UserContentPreferences.fromJson,
toJson: (item) => item.toJson(),
logger: Logger('HtDataMongodb<UserContentPreferences>'),
);
userContentPreferencesRepository = _createRepository(
connection,
'user_content_preferences',
UserContentPreferences.fromJson,
(preferences) {
final json = preferences.toJson();
// These fields are lists of complex objects and must be JSON encoded.
json['followed_topics'] = jsonEncode(json['followed_topics']);
json['followed_sources'] = jsonEncode(json['followed_sources']);
json['followed_countries'] = jsonEncode(json['followed_countries']);
json['saved_headlines'] = jsonEncode(json['saved_headlines']);
return json;
},
final remoteConfigClient = HtDataMongodb<RemoteConfig>(
connectionManager: _mongoDbConnectionManager,
modelName: 'remote_configs',
fromJson: RemoteConfig.fromJson,
toJson: (item) => item.toJson(),
logger: Logger('HtDataMongodb<RemoteConfig>'),
);
remoteConfigRepository = _createRepository(
connection,
'remote_config',
(json) => RemoteConfig.fromJson(_convertTimestampsToString(json)),
(config) {
final json = config.toJson();
// All nested config objects must be JSON encoded for JSONB columns.
json['user_preference_limits'] = jsonEncode(
json['user_preference_limits'],
);
json['ad_config'] = jsonEncode(json['ad_config']);
json['account_action_config'] = jsonEncode(
json['account_action_config'],
);
json['app_status'] = jsonEncode(json['app_status']);
return json;
},

// 4. Initialize Repositories
headlineRepository = HtDataRepository(dataClient: headlineClient);
topicRepository = HtDataRepository(dataClient: topicClient);
sourceRepository = HtDataRepository(dataClient: sourceClient);
countryRepository = HtDataRepository(dataClient: countryClient);
userRepository = HtDataRepository(dataClient: userClient);
userAppSettingsRepository =
HtDataRepository(dataClient: userAppSettingsClient);
userContentPreferencesRepository =
HtDataRepository(dataClient: userContentPreferencesClient);
remoteConfigRepository = HtDataRepository(dataClient: remoteConfigClient);

final emailClient = HtEmailInMemoryClient(
logger: Logger('HtEmailInMemoryClient'),
);
emailRepository = HtEmailRepository(emailClient: emailClient);

// 4. Initialize Services.
emailRepository = const HtEmailRepository(
emailClient: HtEmailInMemoryClient(),
// 5. Initialize Services
tokenBlacklistService = InMemoryTokenBlacklistService(
log: Logger('InMemoryTokenBlacklistService'),
);
tokenBlacklistService = InMemoryTokenBlacklistService(log: _log);
authTokenService = JwtAuthTokenService(
userRepository: userRepository,
blacklistService: tokenBlacklistService,
uuidGenerator: const Uuid(),
log: _log,
log: Logger('JwtAuthTokenService'),
);
verificationCodeStorageService = InMemoryVerificationCodeStorageService();
authService = AuthService(
Expand All @@ -241,7 +175,7 @@ class AppDependencies {
userAppSettingsRepository: userAppSettingsRepository,
userContentPreferencesRepository: userContentPreferencesRepository,
uuidGenerator: const Uuid(),
log: _log,
log: Logger('AuthService'),
);
dashboardSummaryService = DashboardSummaryService(
headlineRepository: headlineRepository,
Expand All @@ -251,40 +185,19 @@ class AppDependencies {
permissionService = const PermissionService();
userPreferenceLimitService = DefaultUserPreferenceLimitService(
remoteConfigRepository: remoteConfigRepository,
log: _log,
log: Logger('DefaultUserPreferenceLimitService'),
);
}

HtDataRepository<T> _createRepository<T>(
Connection connection,
String tableName,
FromJson<T> fromJson,
ToJson<T> toJson,
) {
return HtDataRepository<T>(
dataClient: HtDataPostgresClient<T>(
connection: connection,
tableName: tableName,
fromJson: fromJson,
toJson: toJson,
log: _log,
),
);
_isInitialized = true;
_log.info('Application dependencies initialized successfully.');
}

/// Converts DateTime values in a JSON map to ISO 8601 strings.
///
/// The postgres driver returns DateTime objects for TIMESTAMPTZ columns,
/// but our models' `fromJson` factories expect ISO 8601 strings. This
/// utility function performs the conversion for known timestamp fields.
Map<String, dynamic> _convertTimestampsToString(Map<String, dynamic> json) {
const timestampKeys = {'created_at', 'updated_at'};
final newJson = Map<String, dynamic>.from(json);
for (final key in timestampKeys) {
if (newJson[key] is DateTime) {
newJson[key] = (newJson[key] as DateTime).toIso8601String();
}
}
return newJson;
/// Disposes of resources, such as closing the database connection.
Future<void> dispose() async {
if (!_isInitialized) return;
await _mongoDbConnectionManager.close();
tokenBlacklistService.dispose();
_isInitialized = false;
_log.info('Application dependencies disposed.');
}
}
}

AltStyle γ«γ‚ˆγ£γ¦ε€‰ζ›γ•γ‚ŒγŸγƒšγƒΌγ‚Έ (->γ‚ͺγƒͺγ‚ΈγƒŠγƒ«) /