Skip to content

Fix latest refactor bug #12

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
merged 57 commits into from
Jul 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
3f0805b
fix(api): correct middleware order for Uuid provider
fulleni Jul 6, 2025
3a7b298
fix(api): correct middleware order for Uuid provider
fulleni Jul 6, 2025
4c7031f
feat(api): implement dynamic CORS for local development
fulleni Jul 6, 2025
ab9fb6d
docs(api): update README with new dynamic CORS behavior
fulleni Jul 6, 2025
07363bb
style: format
fulleni Jul 6, 2025
7f1c001
fix(api): correct root middleware execution order for Uuid provider
fulleni Jul 6, 2025
8c2e5ce
format: style
fulleni Jul 6, 2025
0e056ef
fix(api): correct v1 middleware composition to preserve context
fulleni Jul 6, 2025
9a02584
feat(api): add dependency container for service location
fulleni Jul 6, 2025
2880ad6
refactor(api): populate dependency container from server
fulleni Jul 6, 2025
ae9a641
fix(api): provide all dependencies from root middleware
fulleni Jul 6, 2025
0f2e38a
fix(api): implement custom server entrypoint to fix init race condition
fulleni Jul 6, 2025
60d9563
fix(api): resolve startup race condition with initialization gate
fulleni Jul 6, 2025
9860121
fix(api): implement correct server entrypoint with initialization gate
fulleni Jul 6, 2025
ebf04d8
refactor(api): remove server.dart file and related initialization logic
fulleni Jul 6, 2025
edb47ad
refactor(api): explicitly type service variables in server config
fulleni Jul 6, 2025
6013ff8
chore: moved server.dart from config folder to directly under the lib…
fulleni Jul 6, 2025
7dbf541
feat(api): add detailed logging to server initialization sequence
fulleni Jul 6, 2025
3d56532
feat(api): add confirmation logging to dependency container
fulleni Jul 6, 2025
1a0a3bf
feat(api): add logging to root middleware for request tracing
fulleni Jul 6, 2025
78e1030
feat(api): add logging to v1 middleware for CORS and auth tracing
fulleni Jul 6, 2025
5712bf9
refactor(api): centralize DI in root middleware
fulleni Jul 6, 2025
2ceed31
refactor(middleware): remove trailing semicolon
fulleni Jul 6, 2025
41d0726
feat(api): create singleton for database connection management
fulleni Jul 6, 2025
8f55afb
feat(api): create singleton for app dependency management
fulleni Jul 6, 2025
881c6b7
refactor(api): centralize DI in root middleware using singletons
fulleni Jul 6, 2025
dd394d9
refactor(api): remove obsolete server and dependency container files
fulleni Jul 6, 2025
f769493
fix(api): restore root logger configuration in middleware
fulleni Jul 6, 2025
a8eff43
feat(api): add diagnostic logging for environment variables
fulleni Jul 6, 2025
2ecb651
feat(api): add dotenv dependency
fulleni Jul 6, 2025
cd940ef
feat(api): load .env file directly within app dependencies
fulleni Jul 6, 2025
bd1f880
fix(api): correct dotenv initialization and usage
fulleni Jul 6, 2025
87786c2
fix(api): centralize and correct dotenv loading
fulleni Jul 6, 2025
c2d52a5
fix(api): centralize and correct dotenv loading in EnvironmentConfig
fulleni Jul 6, 2025
3704dda
fix(api): align categories schema and seeding with data model
fulleni Jul 6, 2025
f2af724
refactor(api): remove slug from categories table and seeder
fulleni Jul 6, 2025
5351787
fix(api): align categories table schema with model
fulleni Jul 6, 2025
39e4387
await _connection.execute(
fulleni Jul 6, 2025
c5a505a
fix(api): make database seeder resilient to missing optional fields
fulleni Jul 6, 2025
2ed4fbf
feat(api): align sources table schema with data model
fulleni Jul 6, 2025
1ddc3a4
fix(api): align user_app_settings schema with model
fulleni Jul 6, 2025
7369603
fix(api): remove superfluous headquarters object from source seeder p…
fulleni Jul 6, 2025
246f8b0
fix(api): correct source_type column to TEXT in sources schema
fulleni Jul 6, 2025
3dc1109
fix(api): correct seeding order to respect foreign key constraints
fulleni Jul 6, 2025
3b11a62
fix(api): align countries table schema with data model
fulleni Jul 6, 2025
4e9f3ce
fix(api): make headline seeder resilient and align with schema
fulleni Jul 6, 2025
0a777a6
fix(api): align headlines schema and seeder with data model
fulleni Jul 6, 2025
970fced
fix(api): correct syntax and constraints in headlines schema
fulleni Jul 6, 2025
b971f54
fix(api): fully align headlines schema and seeder with model
fulleni Jul 6, 2025
86844c0
fix(api): correctly serialize user preferences for JSONB seeding
fulleni Jul 6, 2025
b007772
fix(api): correctly serialize roles list for admin user seeding
fulleni Jul 6, 2025
fc53d02
fix(api): correctly serialize user roles for database operations
fulleni Jul 6, 2025
5ad4d72
fix(api): handle DateTime deserialization from database for User model
fulleni Jul 6, 2025
43454b4
fix(api): align user_app_settings schema with data model
fulleni Jul 6, 2025
757baa8
fix(api): correctly serialize and deserialize user content preferences
fulleni Jul 6, 2025
292d5c8
fix(api): provide model registry to root middleware
fulleni Jul 6, 2025
b81371a
fix(api): correct data serialization for all repositories
fulleni Jul 6, 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
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,14 @@ for more details.
CONFLICT DO NOTHING` to avoid overwriting existing tables or data.


**Note on Web Client Integration (CORS):**
To allow web applications (like the HT Dashboard) to connect to this API,
the `CORS_ALLOWED_ORIGIN` environment variable must be set to the
specific origin of your web application (e.g., `https://your-dashboard.com`).
For local development, if this variable is not set, the API defaults to
allowing `http://localhost:3000` and issues a console warning. See the
`routes/api/v1/_middleware.dart` file for the exact implementation details.
**Note on Web Client Integration (CORS):** To allow web applications (like
the HT Dashboard) to connect to this API in production, the
`CORS_ALLOWED_ORIGIN` environment variable must be set to the specific
origin of your web application (e.g., `https://your-dashboard.com`).

For local development, the API automatically allows any request
originating from `localhost` on any port, so you do not need to set this
variable.

## ✅ Testing

Expand Down
302 changes: 302 additions & 0 deletions lib/src/config/app_dependencies.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
import 'dart:async';
import 'dart:convert';

import 'package:ht_api/src/config/database_connection.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';
import 'package:ht_api/src/services/dashboard_summary_service.dart';
import 'package:ht_api/src/services/database_seeding_service.dart';
import 'package:ht_api/src/services/default_user_preference_limit_service.dart';
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_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.
class AppDependencies {
AppDependencies._();

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

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

// --- Repositories ---
late final HtDataRepository<Headline> headlineRepository;
late final HtDataRepository<Category> categoryRepository;
late final HtDataRepository<Source> sourceRepository;
late final HtDataRepository<Country> countryRepository;
late final HtDataRepository<User> userRepository;
late final HtDataRepository<UserAppSettings> userAppSettingsRepository;
late final HtDataRepository<UserContentPreferences>
userContentPreferencesRepository;
late final HtDataRepository<AppConfig> appConfigRepository;

// --- Services ---
late final HtEmailRepository emailRepository;
late final TokenBlacklistService tokenBlacklistService;
late final AuthTokenService authTokenService;
late final VerificationCodeStorageService verificationCodeStorageService;
late final AuthService authService;
late final DashboardSummaryService dashboardSummaryService;
late final PermissionService permissionService;
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;
}

_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;

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

// 3. Initialize Repositories.
headlineRepository = _createRepository(
connection,
'headlines',
(json) {
if (json['created_at'] is DateTime) {
json['created_at'] =
(json['created_at'] as DateTime).toIso8601String();
}
if (json['updated_at'] is DateTime) {
json['updated_at'] =
(json['updated_at'] as DateTime).toIso8601String();
}
if (json['published_at'] is DateTime) {
json['published_at'] =
(json['published_at'] as DateTime).toIso8601String();
}
return Headline.fromJson(json);
},
(h) => h.toJson(), // toJson already handles DateTime correctly
);
categoryRepository = _createRepository(
connection,
'categories',
(json) {
if (json['created_at'] is DateTime) {
json['created_at'] =
(json['created_at'] as DateTime).toIso8601String();
}
if (json['updated_at'] is DateTime) {
json['updated_at'] =
(json['updated_at'] as DateTime).toIso8601String();
}
return Category.fromJson(json);
},
(c) => c.toJson(),
);
sourceRepository = _createRepository(
connection,
'sources',
(json) {
if (json['created_at'] is DateTime) {
json['created_at'] =
(json['created_at'] as DateTime).toIso8601String();
}
if (json['updated_at'] is DateTime) {
json['updated_at'] =
(json['updated_at'] as DateTime).toIso8601String();
}
return Source.fromJson(json);
},
(s) => s.toJson(),
);
countryRepository = _createRepository(
connection,
'countries',
(json) {
if (json['created_at'] is DateTime) {
json['created_at'] =
(json['created_at'] as DateTime).toIso8601String();
}
if (json['updated_at'] is DateTime) {
json['updated_at'] =
(json['updated_at'] as DateTime).toIso8601String();
}
return Country.fromJson(json);
},
(c) => c.toJson(),
);
userRepository = _createRepository(
connection,
'users',
(json) {
// The postgres driver returns DateTime objects, but the model's
// fromJson expects ISO 8601 strings. We must convert them first.
if (json['created_at'] is DateTime) {
json['created_at'] = (json['created_at'] as DateTime).toIso8601String();
}
if (json['last_engagement_shown_at'] is DateTime) {
json['last_engagement_shown_at'] =
(json['last_engagement_shown_at'] as DateTime).toIso8601String();
}
return User.fromJson(json);
},
(user) {
// The `roles` field is a List<String>, but the database expects a
// JSONB array. We must explicitly encode it.
final json = user.toJson();
json['roles'] = jsonEncode(json['roles']);
return json;
},
);
userAppSettingsRepository = _createRepository(
connection,
'user_app_settings',
(json) {
// The DB has created_at/updated_at, but the model doesn't.
// Remove them before deserialization to avoid CheckedFromJsonException.
json.remove('created_at');
json.remove('updated_at');
return UserAppSettings.fromJson(json);
},
(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']);
json['engagement_shown_counts'] =
jsonEncode(json['engagement_shown_counts']);
json['engagement_last_shown_timestamps'] =
jsonEncode(json['engagement_last_shown_timestamps']);
return json;
},
);
userContentPreferencesRepository = _createRepository(
connection,
'user_content_preferences',
(json) {
// The postgres driver returns DateTime objects, but the model's
// fromJson expects ISO 8601 strings. We must convert them first.
if (json['created_at'] is DateTime) {
json['created_at'] =
(json['created_at'] as DateTime).toIso8601String();
}
if (json['updated_at'] is DateTime) {
json['updated_at'] =
(json['updated_at'] as DateTime).toIso8601String();
}
return UserContentPreferences.fromJson(json);
},
(preferences) {
final json = preferences.toJson();
json['followed_categories'] = jsonEncode(json['followed_categories']);
json['followed_sources'] = jsonEncode(json['followed_sources']);
json['followed_countries'] = jsonEncode(json['followed_countries']);
json['saved_headlines'] = jsonEncode(json['saved_headlines']);
return json;
},
);
appConfigRepository = _createRepository(
connection,
'app_config',
(json) {
if (json['created_at'] is DateTime) {
json['created_at'] =
(json['created_at'] as DateTime).toIso8601String();
}
if (json['updated_at'] is DateTime) {
json['updated_at'] =
(json['updated_at'] as DateTime).toIso8601String();
}
return AppConfig.fromJson(json);
},
(c) => c.toJson(),
);

// 4. Initialize Services.
emailRepository = const HtEmailRepository(
emailClient: HtEmailInMemoryClient(),
);
tokenBlacklistService = InMemoryTokenBlacklistService();
authTokenService = JwtAuthTokenService(
userRepository: userRepository,
blacklistService: tokenBlacklistService,
uuidGenerator: const Uuid(),
);
verificationCodeStorageService = InMemoryVerificationCodeStorageService();
authService = AuthService(
userRepository: userRepository,
authTokenService: authTokenService,
verificationCodeStorageService: verificationCodeStorageService,
emailRepository: emailRepository,
userAppSettingsRepository: userAppSettingsRepository,
userContentPreferencesRepository: userContentPreferencesRepository,
uuidGenerator: const Uuid(),
);
dashboardSummaryService = DashboardSummaryService(
headlineRepository: headlineRepository,
categoryRepository: categoryRepository,
sourceRepository: sourceRepository,
);
permissionService = const PermissionService();
userPreferenceLimitService = DefaultUserPreferenceLimitService(
appConfigRepository: appConfigRepository,
);
}

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,
),
);
}
}
67 changes: 67 additions & 0 deletions lib/src/config/database_connection.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import 'dart:async';

import 'package:ht_api/src/config/environment_config.dart';
import 'package:logging/logging.dart';
import 'package:postgres/postgres.dart';

/// A singleton class to manage a single, shared PostgreSQL database connection.
///
/// This pattern ensures that the application establishes a connection to the
/// database only once and reuses it for all subsequent operations, which is
/// crucial for performance and resource management.
class DatabaseConnectionManager {
// Private constructor for the singleton pattern.
DatabaseConnectionManager._();

/// The single, global instance of the [DatabaseConnectionManager].
static final instance = DatabaseConnectionManager._();

final _log = Logger('DatabaseConnectionManager');

// A completer to signal when the database connection is established.
final _completer = Completer<Connection>();

/// Returns a future that completes with the established database connection.
///
/// If the connection has not been initialized yet, it calls `init()` to
/// establish it. Subsequent calls will return the same connection future.
Future<Connection> get connection => _completer.future;

/// Initializes the database connection.
///
/// This method is idempotent. It parses the database URL from the
/// environment, opens a connection to the PostgreSQL server, and completes
/// the `_completer` with the connection. It only performs the connection
/// logic on the very first call.
Future<void> init() async {
if (_completer.isCompleted) {
_log.fine('Database connection already initializing/initialized.');
return;
}

_log.info('Initializing database connection...');
final dbUri = Uri.parse(EnvironmentConfig.databaseUrl);
String? username;
String? password;
if (dbUri.userInfo.isNotEmpty) {
final parts = dbUri.userInfo.split(':');
username = Uri.decodeComponent(parts.first);
if (parts.length > 1) {
password = Uri.decodeComponent(parts.last);
}
}

final connection = await Connection.open(
Endpoint(
host: dbUri.host,
port: dbUri.port,
database: dbUri.path.substring(1),
username: username,
password: password,
),
settings: const ConnectionSettings(sslMode: SslMode.require),
);
_log.info('Database connection established successfully.');
_completer.complete(connection);
}
}
Loading
Loading