From 2b8419b27a7ce9e21d9eea5dd55ca933d9331425 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 09:36:55 +0100 Subject: [PATCH 01/18] chore: add ht_data_postgres dependency and update logging version --- pubspec.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pubspec.yaml b/pubspec.yaml index 48c9d55..ba0bb5d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,9 @@ dependencies: ht_data_inmemory: git: url: https://github.com/headlines-toolkit/ht-data-inmemory.git + ht_data_postgres: + git: + url: https://github.com/headlines-toolkit/ht-data-postgres.git ht_data_repository: git: url: https://github.com/headlines-toolkit/ht-data-repository.git @@ -34,7 +37,9 @@ dependencies: git: url: https://github.com/headlines-toolkit/ht-shared.git + logging: ^1.3.0 meta: ^1.16.0 + postgres: ^3.5.6 shelf_cors_headers: ^0.1.5 uuid: ^4.5.1 From 9b07fdb7674b23992ca97beecad601087513a2e3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 09:37:01 +0100 Subject: [PATCH 02/18] feat(config): add EnvironmentConfig class for managing environment variables --- lib/src/config/environment_config.dart | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 lib/src/config/environment_config.dart diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart new file mode 100644 index 0000000..a7172fc --- /dev/null +++ b/lib/src/config/environment_config.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +/// {@template environment_config} +/// A utility class for accessing environment variables. +/// +/// This class provides a centralized way to read configuration values +/// from the environment, ensuring that critical settings like database +/// connection strings are managed outside of the source code. +/// {@endtemplate} +abstract final class EnvironmentConfig { + /// Retrieves the PostgreSQL database connection URI from the environment. + /// + /// The value is read from the `DATABASE_URL` environment variable. + /// + /// Throws a [StateError] if the `DATABASE_URL` environment variable is not + /// set, as the application cannot function without it. + static String get databaseUrl { + final dbUrl = Platform.environment['DATABASE_URL']; + if (dbUrl == null || dbUrl.isEmpty) { + throw StateError( + 'FATAL: DATABASE_URL environment variable is not set. ' + 'The application cannot start without a database connection.', + ); + } + return dbUrl; + } + + /// Retrieves the current environment mode (e.g., 'development', 'production'). + /// + /// The value is read from the `ENV` environment variable. + /// Defaults to 'production' if the variable is not set. + static String get environment => Platform.environment['ENV'] ?? 'production'; +} From e43c5f3098236a8f5770cbd6e7038e4dbce86d38 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:00:40 +0100 Subject: [PATCH 03/18] feat(core): create server entrypoint for db connection and logging --- lib/src/config/server.dart | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 lib/src/config/server.dart diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart new file mode 100644 index 0000000..876d404 --- /dev/null +++ b/lib/src/config/server.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_api/src/config/environment_config.dart'; +import 'package:logging/logging.dart'; +import 'package:postgres/postgres.dart'; + +/// Global logger instance. +final _log = Logger('ht_api'); + +/// Global PostgreSQL connection instance. +late final Connection _connection; + +/// The main entry point for the server. +/// +/// This function is responsible for: +/// 1. Setting up the global logger. +/// 2. Establishing the PostgreSQL database connection. +/// 3. Providing these dependencies to the Dart Frog handler. +/// 4. Gracefully closing the database connection on server shutdown. +Future run(Handler handler, InternetAddress ip, int port) async { + // 1. Setup Logger + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + // ignore: avoid_print + print( + '${record.level.name}: ${record.time}: ' + '${record.loggerName}: ${record.message}', + ); + }); + + // 2. Establish Database Connection + _log.info('Connecting to PostgreSQL database...'); + _connection = await Connection.open( + Endpoint.uri(Uri.parse(EnvironmentConfig.databaseUrl)), + settings: const ConnectionSettings(sslMode: SslMode.prefer), + ); + _log.info('PostgreSQL database connection established.'); + + // 3. Start the server and set up shutdown logic + return serve( + handler, + ip, + port, + onShutdown: () async { + _log.info('Server shutting down. Closing database connection...'); + await _connection.close(); + _log.info('Database connection closed.'); + }, + ); +} \ No newline at end of file From 50f101edb28a0535dbb9657c111185aff23cc39c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:03:33 +0100 Subject: [PATCH 04/18] refactor(core): strip middleware of in-memory dependency injection Removes all repository and service instantiation from the root middleware. This is a preparatory step for centralizing dependency injection in `server.dart`. The API is temporarily broken by this change. --- routes/_middleware.dart | 314 ++-------------------------------------- 1 file changed, 11 insertions(+), 303 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 15ae1ef..8f7b678 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -1,25 +1,7 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/middlewares/error_handler.dart'; -import 'package:ht_api/src/rbac/permission_service.dart'; -import 'package:ht_api/src/registry/model_registry.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/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_inmemory/ht_data_inmemory.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:uuid/uuid.dart'; -// Assuming a fixed ID for the AppConfig document -const String _appConfigId = 'app_config'; - // --- Request ID Wrapper --- /// {@template request_id} @@ -62,296 +44,22 @@ class RequestId { final String id; } -// --- Repository Creation Logic --- -HtDataRepository _createHeadlineRepository() { - print('Initializing Headline Repository...'); - final initialData = headlinesFixturesData.map(Headline.fromJson).toList(); - final client = HtDataInMemory( - toJson: (i) => i.toJson(), - getId: (i) => i.id, - initialData: initialData, - ); - print('Headline Repository Initialized with ${initialData.length} items.'); - return HtDataRepository(dataClient: client); -} - -HtDataRepository _createCategoryRepository() { - print('Initializing Category Repository...'); - final initialData = categoriesFixturesData.map(Category.fromJson).toList(); - final client = HtDataInMemory( - toJson: (i) => i.toJson(), - getId: (i) => i.id, - initialData: initialData, - ); - print('Category Repository Initialized with ${initialData.length} items.'); - return HtDataRepository(dataClient: client); -} - -HtDataRepository _createSourceRepository() { - print('Initializing Source Repository...'); - final initialData = sourcesFixturesData.map(Source.fromJson).toList(); - final client = HtDataInMemory( - toJson: (i) => i.toJson(), - getId: (i) => i.id, - initialData: initialData, - ); - print('Source Repository Initialized with ${initialData.length} items.'); - return HtDataRepository(dataClient: client); -} - -HtDataRepository _createCountryRepository() { - print('Initializing Country Repository...'); - final initialData = countriesFixturesData.map(Country.fromJson).toList(); - final client = HtDataInMemory( - toJson: (i) => i.toJson(), - getId: (i) => i.id, - initialData: initialData, - ); - print('Country Repository Initialized with ${initialData.length} items.'); - return HtDataRepository(dataClient: client); -} - -HtDataRepository _createAdminUserRepository() { - print('Initializing User Repository with Admin...'); - // This assumes `adminUserFixtureData` is available from `ht_shared`. - final initialData = usersFixturesData; - final client = HtDataInMemory( - toJson: (u) => u.toJson(), - getId: (u) => u.id, - initialData: initialData, - ); - print('User Repository Initialized with admin user.'); - return HtDataRepository(dataClient: client); -} - -// New repositories for user settings and preferences -HtDataRepository _createUserAppSettingsRepository() { - print('Initializing UserAppSettings Repository...'); - final client = HtDataInMemory( - toJson: (i) => i.toJson(), - getId: (i) => i.id, - // User settings are created on demand, no initial fixture needed - ); - print('UserAppSettings Repository Initialized.'); - return HtDataRepository(dataClient: client); -} - -HtDataRepository -_createUserContentPreferencesRepository() { - print('Initializing UserContentPreferences Repository...'); - final client = HtDataInMemory( - toJson: (i) => i.toJson(), - getId: (i) => i.id, - // User preferences are created on demand, no initial fixture needed - ); - print('UserContentPreferences Repository Initialized.'); - return HtDataRepository(dataClient: client); -} - -HtDataRepository _createAppConfigRepository() { - print('Initializing AppConfig Repository...'); - final initialData = [ - AppConfig.fromJson(appConfigFixtureData), - ]; // Assuming one config - final client = HtDataInMemory( - toJson: (i) => i.toJson(), - getId: (i) => i.id, - initialData: initialData, - ); - print('AppConfig Repository Initialized.'); - return HtDataRepository(dataClient: client); -} - -/// Middleware to asynchronously load and provide the AppConfig. -Middleware _appConfigProviderMiddleware() { - return (handler) { - return (context) async { - // Read the AppConfigRepository from the context - final appConfigRepository = context.read>(); - // Read the AppConfig instance - final appConfig = await appConfigRepository.read(id: _appConfigId); - // Provide the AppConfig instance to downstream handlers/middleware - return handler(context.provide(() => appConfig)); - }; - }; -} - // --- Middleware Definition --- Handler middleware(Handler handler) { - // Initialize repositories when middleware is first created - // This ensures they are singletons for the server instance. - final headlineRepository = _createHeadlineRepository(); - final categoryRepository = _createCategoryRepository(); - final sourceRepository = _createSourceRepository(); - final countryRepository = _createCountryRepository(); - final userSettingsRepository = _createUserAppSettingsRepository(); // New - final userContentPreferencesRepository = - _createUserContentPreferencesRepository(); - final appConfigRepository = _createAppConfigRepository(); - - // Instantiate the new DashboardSummaryService with its dependencies - final dashboardSummaryService = DashboardSummaryService( - headlineRepository: headlineRepository, - categoryRepository: categoryRepository, - sourceRepository: sourceRepository, - ); - print('[MiddlewareSetup] DashboardSummaryService instantiated.'); - - const uuid = Uuid(); - - // --- Auth Dependencies --- - // User Repo with pre-loaded admin user - final userRepository = _createAdminUserRepository(); - print('[MiddlewareSetup] HtDataRepository with admin user instantiated.'); - // Email Repo (using InMemory) - const emailRepository = HtEmailRepository( - emailClient: HtEmailInMemoryClient(), - ); - print('[MiddlewareSetup] HtEmailRepository instantiated.'); - // Auth Services (using JWT and in-memory implementations) - final tokenBlacklistService = InMemoryTokenBlacklistService(); - print('[MiddlewareSetup] InMemoryTokenBlacklistService instantiated.'); - // Instantiate the JWT service, passing its dependencies - final authTokenService = JwtAuthTokenService( - userRepository: userRepository, - blacklistService: tokenBlacklistService, - uuidGenerator: uuid, - ); - print('[MiddlewareSetup] JwtAuthTokenService instantiated.'); - final verificationCodeStorageService = - InMemoryVerificationCodeStorageService(); - print( - '[MiddlewareSetup] InMemoryVerificationCodeStorageService instantiated.', - ); - final authService = AuthService( - userRepository: userRepository, - authTokenService: authTokenService, - verificationCodeStorageService: verificationCodeStorageService, - emailRepository: emailRepository, - userAppSettingsRepository: userSettingsRepository, - userContentPreferencesRepository: userContentPreferencesRepository, - uuidGenerator: uuid, - ); - print('[MiddlewareSetup] AuthService instantiated.'); - - // --- RBAC Dependencies --- - const permissionService = PermissionService(); - - // --- User Preference Limit Service --- - final userPreferenceLimitService = DefaultUserPreferenceLimitService( - appConfigRepository: appConfigRepository, - ); - print('[MiddlewareSetup] DefaultUserPreferenceLimitService instantiated.'); - - // ========================================================================== - // IMPORTANT: The order of middleware matters significantly! - // Middleware is applied in layers (like an onion). A request flows "in" - // through the chain, hits the route handler, and the response flows "out". - // Providers must be added *before* the middleware/handlers that read them. - // Error handlers should typically be placed late in the "request" phase - // (or early in the "response" phase) to catch errors from upstream. - // ========================================================================== + // This middleware chain will be rebuilt in a later step. + // For now, it only provides a request ID and basic error handling. return handler - // Add the asynchronous AppConfig provider middleware here - .use(_appConfigProviderMiddleware()) - // --- 1. Request ID Provider (Early Setup) --- - // PURPOSE: Generates a unique ID (UUID v4) for each incoming request. - // Provides `RequestId` instance via context. - // ORDER: Placed *very early* so the ID is available for logging and - // tracing throughout the entire request lifecycle in all - // subsequent middleware and handlers. - .use((innerHandler) { - return (context) { - final requestIdValue = uuid.v4(); - final requestId = RequestId(requestIdValue); - // Provide the RequestId instance to downstream handlers/middleware - return innerHandler(context.provide(() => requestId)); - }; - }) - // --- 2. Model Registry Provider (Early Setup) --- - // PURPOSE: Provides the `ModelRegistry` map for dynamic JSON - // serialization/deserialization lookups. - // ORDER: Needed by some repository clients or handlers dealing with - // generic data types. Placed early, after RequestId. - .use(modelRegistryProvider) - // --- 3. Repository Providers (Core Data Access) --- - // PURPOSE: Provide singleton instances of all data repositories. - // ORDER: These MUST be provided BEFORE any middleware or route handlers - // that need to interact with data (e.g., AuthService, - // authenticationProvider indirectly via AuthService/TokenService, - // specific route logic). - .use(provider>((_) => headlineRepository)) - .use(provider>((_) => categoryRepository)) - .use(provider>((_) => sourceRepository)) - .use(provider>((_) => countryRepository)) - .use( - provider>((_) => userRepository), - ) // Used by Auth services - .use( - provider((_) => emailRepository), - ) // Used by AuthService - // New Repositories for User Settings and Preferences - .use( - provider>( - (_) => userSettingsRepository, - ), - ) - .use( - provider>( - (_) => userContentPreferencesRepository, - ), - ) - .use(provider>((_) => appConfigRepository)) - // ORDER: These MUST be provided BEFORE `authenticationProvider` and - // any route handlers that perform authentication/authorization. - // - `Uuid` is used by `AuthService` and `JwtAuthTokenService`. - // - `AuthTokenService` is read by `authenticationProvider`. - // - `AuthService` uses several repositories and `AuthTokenService`. - // - `VerificationCodeStorageService` is used by `AuthService`. - // - `TokenBlacklistService` is used by `JwtAuthTokenService`. - .use(provider((_) => uuid)) // Read by AuthService & TokenService - .use( - provider((_) => tokenBlacklistService), - ) // Read by AuthTokenService - .use( - provider((_) => authTokenService), - ) // Read by AuthService - .use( - provider( - (_) => verificationCodeStorageService, - ), - ) // Read by AuthService - .use( - provider((_) => authService), - ) // Reads other services/repos - .use(provider((_) => dashboardSummaryService)) - // --- 5. RBAC Service Provider --- - // PURPOSE: Provides the PermissionService for authorization checks. - // ORDER: Must be provided before any middleware or handlers that use it - // (e.g., authorizationMiddleware). - .use(provider((_) => permissionService)) - // --- 6. User Preference Limit Service Provider --- // New - // PURPOSE: Provides the service for enforcing user preference limits. - // ORDER: Must be provided before any handlers that use it (specifically - // the generic data route handlers for UserContentPreferences). .use( - provider((_) => userPreferenceLimitService), + (innerHandler) { + return (context) { + // In a later step, the Uuid instance will be provided from server.dart + // For now, we create it here. + const uuid = Uuid(); + final requestId = RequestId(uuid.v4()); + return innerHandler(context.provide(() => requestId)); + }; + }, ) - // --- 7. Request Logger (Logging) --- - // PURPOSE: Logs details about the incoming request and outgoing response. - // ORDER: Often placed late in the request phase / early in the response - // phase. Placing it here logs the request *before* the handler - // runs and the response *after* the handler (and error handler) - // completes. Can access `RequestId` and potentially `User?`. .use(requestLogger()) - // --- 8. Error Handler (Catch-All) --- - // PURPOSE: Catches exceptions thrown by upstream middleware or route - // handlers and converts them into standardized JSON error responses. - // ORDER: MUST be placed *late* in the chain (typically last before the - // actual handler is invoked by the framework, or first in the - // response processing flow) so it can catch errors from - // everything that came before it (providers, auth middleware, - // route handlers). If placed too early, it won't catch errors - // from middleware/handlers defined after it. .use(errorHandler()); } From c2aff30c590706b61816ae44436cf63b5a84641c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:15:26 +0100 Subject: [PATCH 05/18] fix(core): correct db connection and provide all repositories - Fixes a critical error in `server.dart` where `Endpoint.uri` was called, which is not available in the current `postgres` package version. The `DATABASE_URL` is now parsed manually to create the `Endpoint`. - Initializes all data repositories (`User`, `UserAppSettings`, `UserContentPreferences`, `AppConfig`) in addition to the existing ones. - Wraps the main handler with `provider` middleware for all repositories and `Uuid`, making them available for dependency injection throughout the application. --- lib/src/config/server.dart | 142 +++++++++++++++++++++++++++++++++---- 1 file changed, 128 insertions(+), 14 deletions(-) diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart index 876d404..ebe96bd 100644 --- a/lib/src/config/server.dart +++ b/lib/src/config/server.dart @@ -1,15 +1,39 @@ -import 'dart:io'; + import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/config/environment_config.dart'; +import 'package:ht_data_postgres/ht_data_postgres.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; import 'package:logging/logging.dart'; import 'package:postgres/postgres.dart'; +import 'package:uuid/uuid.dart'; /// Global logger instance. -final _log = Logger('ht_api'); + final _log = Logger('ht_api'); /// Global PostgreSQL connection instance. -late final Connection _connection; + late final Connection _connection; + +/// Creates a data repository for a given type [T]. +/// +/// This helper function centralizes the creation of repositories, +/// ensuring they all use the same database connection and logger. + HtDataRepository _createRepository({ + required String tableName, + required FromJson fromJson, + required ToJson toJson, + }) { + return HtDataRepository( + dataClient: HtDataPostgresClient( + connection: _connection, + tableName: tableName, + fromJson: fromJson, + toJson: toJson, + log: _log, + ), + ); + } /// The main entry point for the server. /// @@ -31,21 +55,111 @@ Future run(Handler handler, InternetAddress ip, int port) async { // 2. Establish Database Connection _log.info('Connecting to PostgreSQL database...'); + 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); + } + } + _connection = await Connection.open( - Endpoint.uri(Uri.parse(EnvironmentConfig.databaseUrl)), - settings: const ConnectionSettings(sslMode: SslMode.prefer), + Endpoint( + host: dbUri.host, + port: dbUri.port, + database: dbUri.path.substring(1), // Remove leading '/' + username: username, + password: password, + ), + // Using `require` is a more secure default. For local development against + // a non-SSL database, this may need to be changed to `SslMode.disable`. + settings: const ConnectionSettings(sslMode: SslMode.require), ); _log.info('PostgreSQL database connection established.'); - // 3. Start the server and set up shutdown logic - return serve( - handler, + // 3. Initialize Repositories + final headlineRepository = _createRepository( + tableName: 'headlines', + fromJson: Headline.fromJson, + toJson: (h) => h.toJson(), + ); + final categoryRepository = _createRepository( + tableName: 'categories', + fromJson: Category.fromJson, + toJson: (c) => c.toJson(), + ); + final sourceRepository = _createRepository( + tableName: 'sources', + fromJson: Source.fromJson, + toJson: (s) => s.toJson(), + ); + final countryRepository = _createRepository( + tableName: 'countries', + fromJson: Country.fromJson, + toJson: (c) => c.toJson(), + ); + final userRepository = _createRepository( + tableName: 'users', + fromJson: User.fromJson, + toJson: (u) => u.toJson(), + ); + final userAppSettingsRepository = _createRepository( + tableName: 'user_app_settings', + fromJson: UserAppSettings.fromJson, + toJson: (s) => s.toJson(), + ); + final userContentPreferencesRepository = + _createRepository( + tableName: 'user_content_preferences', + fromJson: UserContentPreferences.fromJson, + toJson: (p) => p.toJson(), + ); + final appConfigRepository = _createRepository( + tableName: 'app_config', + fromJson: AppConfig.fromJson, + toJson: (c) => c.toJson(), + ); + + // 4. Create the main handler with all dependencies provided + final finalHandler = handler + .use(provider((_) => const Uuid())) + .use(provider>((_) => headlineRepository)) + .use(provider>((_) => categoryRepository)) + .use(provider>((_) => sourceRepository)) + .use(provider>((_) => countryRepository)) + .use(provider>((_) => userRepository)) + .use( + provider>( + (_) => userAppSettingsRepository, + ), + ) + .use( + provider>( + (_) => userContentPreferencesRepository, + ), + ) + .use(provider>((_) => appConfigRepository)); + + // 5. Start the server + final server = await serve( + finalHandler, ip, port, - onShutdown: () async { - _log.info('Server shutting down. Closing database connection...'); - await _connection.close(); - _log.info('Database connection closed.'); - }, ); -} \ No newline at end of file + _log.info('Server listening on port ${server.port}'); + + // 6. Handle graceful shutdown + ProcessSignal.sigint.watch().listen((_) async { + _log.info('Received SIGINT. Shutting down...'); + await _connection.close(); + _log.info('Database connection closed.'); + await server.close(force: true); + _log.info('Server shut down.'); + exit(0); + }); + + return server; + } \ No newline at end of file From 6dcbc8adcee429b747ff98d82f4a01a05f38c904 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:17:19 +0100 Subject: [PATCH 06/18] feat(server): add ht_data_client import for data handling --- lib/src/config/server.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart index ebe96bd..512ee76 100644 --- a/lib/src/config/server.dart +++ b/lib/src/config/server.dart @@ -2,6 +2,7 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/config/environment_config.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_shared/ht_shared.dart'; From 03eca2dad2fafa20e2830020647338fd72cdea21 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:21:11 +0100 Subject: [PATCH 07/18] feat(core): initialize and provide application services in server This commit updates `server.dart` to instantiate all application services, such as AuthService and DashboardSummaryService, injecting the necessary repository dependencies. These services are then provided to the application via the Dart Frog provider middleware, completing the centralized dependency injection setup. --- lib/src/config/server.dart | 72 +++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart index 512ee76..447c7d1 100644 --- a/lib/src/config/server.dart +++ b/lib/src/config/server.dart @@ -2,9 +2,20 @@ import 'package:dart_frog/dart_frog.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'; +import 'package:ht_api/src/services/dashboard_summary_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'; @@ -124,9 +135,43 @@ Future run(Handler handler, InternetAddress ip, int port) async { toJson: (c) => c.toJson(), ); - // 4. Create the main handler with all dependencies provided + // 4. Initialize Services + const uuid = Uuid(); + const emailRepository = HtEmailRepository( + emailClient: HtEmailInMemoryClient(), + ); + final tokenBlacklistService = InMemoryTokenBlacklistService(); + final authTokenService = JwtAuthTokenService( + userRepository: userRepository, + blacklistService: tokenBlacklistService, + uuidGenerator: uuid, + ); + final verificationCodeStorageService = + InMemoryVerificationCodeStorageService(); + final authService = AuthService( + userRepository: userRepository, + authTokenService: authTokenService, + verificationCodeStorageService: verificationCodeStorageService, + emailRepository: emailRepository, + userAppSettingsRepository: userAppSettingsRepository, + userContentPreferencesRepository: userContentPreferencesRepository, + uuidGenerator: uuid, + ); + final dashboardSummaryService = DashboardSummaryService( + headlineRepository: headlineRepository, + categoryRepository: categoryRepository, + sourceRepository: sourceRepository, + ); + const permissionService = PermissionService(); + final userPreferenceLimitService = DefaultUserPreferenceLimitService( + appConfigRepository: appConfigRepository, + ); + + // 5. Create the main handler with all dependencies provided final finalHandler = handler - .use(provider((_) => const Uuid())) + // Foundational utilities + .use(provider((_) => uuid)) + // Repositories .use(provider>((_) => headlineRepository)) .use(provider>((_) => categoryRepository)) .use(provider>((_) => sourceRepository)) @@ -142,9 +187,26 @@ Future run(Handler handler, InternetAddress ip, int port) async { (_) => userContentPreferencesRepository, ), ) - .use(provider>((_) => appConfigRepository)); + .use(provider>((_) => appConfigRepository)) + .use(provider((_) => emailRepository)) + // Services + .use(provider((_) => tokenBlacklistService)) + .use(provider((_) => authTokenService)) + .use( + provider( + (_) => verificationCodeStorageService, + ), + ) + .use(provider((_) => authService)) + .use(provider((_) => dashboardSummaryService)) + .use(provider((_) => permissionService)) + .use( + provider( + (_) => userPreferenceLimitService, + ), + ); - // 5. Start the server + // 6. Start the server final server = await serve( finalHandler, ip, @@ -152,7 +214,7 @@ Future run(Handler handler, InternetAddress ip, int port) async { ); _log.info('Server listening on port ${server.port}'); - // 6. Handle graceful shutdown + // 7. Handle graceful shutdown ProcessSignal.sigint.watch().listen((_) async { _log.info('Received SIGINT. Shutting down...'); await _connection.close(); From 494d83a2ad20fce63b10d0fc05046819d35e76b0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:23:10 +0100 Subject: [PATCH 08/18] refactor(core): rebuild root middleware to consume providers Updates `routes/_middleware.dart` to reflect the new dependency injection architecture. The middleware now consumes the `Uuid` provider from the context to generate a request ID. The chain is simplified to apply only the essential global middleware: request ID generation, request logging, and error handling. --- routes/_middleware.dart | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 8f7b678..6d344a8 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -1,5 +1,6 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/middlewares/error_handler.dart'; +import 'package:ht_api/src/middlewares/request_logger.dart'; import 'package:uuid/uuid.dart'; // --- Request ID Wrapper --- @@ -46,15 +47,18 @@ class RequestId { // --- Middleware Definition --- Handler middleware(Handler handler) { - // This middleware chain will be rebuilt in a later step. - // For now, it only provides a request ID and basic error handling. + // This is the root middleware chain for the entire API. + // The order is important: + // 1. Request ID: Assigns a unique ID to each request for tracing. + // 2. Request Logger: Logs request and response details. + // 3. Error Handler: Catches all errors and formats them into a standard + // JSON response. return handler .use( (innerHandler) { return (context) { - // In a later step, the Uuid instance will be provided from server.dart - // For now, we create it here. - const uuid = Uuid(); + // Read the singleton Uuid instance provided from server.dart. + final uuid = context.read(); final requestId = RequestId(uuid.v4()); return innerHandler(context.provide(() => requestId)); }; From d20b3aaf60437ed67ace6e970c26a45288dac127 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:26:36 +0100 Subject: [PATCH 09/18] refactor(dependencies): remove ht_data_inmemory dependency from pubspec.yaml --- pubspec.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index ba0bb5d..039d10b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,9 +12,6 @@ dependencies: ht_data_client: git: url: https://github.com/headlines-toolkit/ht-data-client.git - ht_data_inmemory: - git: - url: https://github.com/headlines-toolkit/ht-data-inmemory.git ht_data_postgres: git: url: https://github.com/headlines-toolkit/ht-data-postgres.git From 8013166bc9ec457155351bc42ead85efce189e09 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:28:59 +0100 Subject: [PATCH 10/18] feat(data): create initial DatabaseSeedingService class Adds the foundational `DatabaseSeedingService` class. This service will be responsible for all database schema creation and data seeding operations. --- .../services/database_seeding_service.dart | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 lib/src/services/database_seeding_service.dart diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart new file mode 100644 index 0000000..dd9e066 --- /dev/null +++ b/lib/src/services/database_seeding_service.dart @@ -0,0 +1,24 @@ +import 'package:logging/logging.dart'; +import 'package:postgres/postgres.dart'; + +/// {@template database_seeding_service} +/// A service responsible for initializing the database schema and seeding it +/// with initial data. +/// +/// This service is intended to be run at application startup, particularly +/// in development environments or during the first run of a production instance +/// to set up the initial admin user and default configuration. +/// {@endtemplate} +class DatabaseSeedingService { + /// {@macro database_seeding_service} + const DatabaseSeedingService({ + required Connection connection, + required Logger log, + }) : _connection = connection, + _log = log; + + final Connection _connection; + final Logger _log; + + // Methods for table creation and data seeding will be added here. +} From 8c2c5796fecd391dd3cc76903b8d6729ed7d157c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:31:25 +0100 Subject: [PATCH 11/18] feat(data): implement table creation in DatabaseSeedingService Adds the `createTables` method to the `DatabaseSeedingService`. This method defines and executes the SQL `CREATE TABLE IF NOT EXISTS` statements for all application models within a single transaction. The schema includes primary keys, foreign keys with cascading deletes, and appropriate data types like `JSONB` and `TIMESTAMPTZ`. --- .../services/database_seeding_service.dart | 118 +++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index dd9e066..44f1648 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -1,5 +1,6 @@ import 'package:logging/logging.dart'; import 'package:postgres/postgres.dart'; +import 'package:ht_shared/ht_shared.dart'; /// {@template database_seeding_service} /// A service responsible for initializing the database schema and seeding it @@ -20,5 +21,120 @@ class DatabaseSeedingService { final Connection _connection; final Logger _log; - // Methods for table creation and data seeding will be added here. + /// Creates all necessary tables in the database if they do not already exist. + /// + /// This method executes a series of `CREATE TABLE IF NOT EXISTS` statements + /// within a single transaction to ensure atomicity. + Future createTables() async { + _log.info('Starting database schema creation...'); + try { + await _connection.transaction((ctx) async { + _log.fine('Creating "users" table...'); + await ctx.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE, + roles JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_engagement_shown_at TIMESTAMPTZ + ); + '''); + + _log.fine('Creating "app_config" table...'); + await ctx.execute(''' + CREATE TABLE IF NOT EXISTS app_config ( + id TEXT PRIMARY KEY, + user_preference_limits JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ + ); + '''); + + _log.fine('Creating "categories" table...'); + await ctx.execute(''' + CREATE TABLE IF NOT EXISTS categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ + ); + '''); + + _log.fine('Creating "sources" table...'); + await ctx.execute(''' + CREATE TABLE IF NOT EXISTS sources ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ + ); + '''); + + _log.fine('Creating "countries" table...'); + await ctx.execute(''' + CREATE TABLE IF NOT EXISTS countries ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + code TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ + ); + '''); + + _log.fine('Creating "headlines" table...'); + await ctx.execute(''' + CREATE TABLE IF NOT EXISTS headlines ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + source_id TEXT NOT NULL, + category_id TEXT NOT NULL, + image_url TEXT NOT NULL, + url TEXT NOT NULL, + published_at TIMESTAMPTZ NOT NULL, + description TEXT, + content TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ + ); + '''); + + _log.fine('Creating "user_app_settings" table...'); + await ctx.execute(''' + CREATE TABLE IF NOT EXISTS user_app_settings ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + display_settings JSONB NOT NULL, + language JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ + ); + '''); + + _log.fine('Creating "user_content_preferences" table...'); + await ctx.execute(''' + CREATE TABLE IF NOT EXISTS user_content_preferences ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + followed_categories JSONB NOT NULL, + followed_sources JSONB NOT NULL, + followed_countries JSONB NOT NULL, + saved_headlines JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ + ); + '''); + }); + _log.info('Database schema creation completed successfully.'); + } on Object catch (e, st) { + _log.severe( + 'An error occurred during database schema creation.', + e, + st, + ); + // Propagate as a standard exception for the server to handle. + throw OperationFailedException( + 'Failed to initialize database schema: $e', + ); + } + } } From 04189f32f48f44a75c35664390defa538fcf8bc5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:39:55 +0100 Subject: [PATCH 12/18] feat(data): implement global fixture data seeding - Implements the `seedGlobalFixtureData` method in `DatabaseSeedingService` to populate the database with initial categories, sources, countries, and headlines from the shared fixtures. - Uses `ON CONFLICT DO NOTHING` to make the seeding process idempotent. - Fixes the `createTables` method by replacing the problematic `.transaction()` helper with a manual `BEGIN`/`COMMIT`/`ROLLBACK` block for compatibility and robustness. --- .../services/database_seeding_service.dart | 113 ++++++++++++++++-- 1 file changed, 103 insertions(+), 10 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 44f1648..e69aae8 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -28,9 +28,14 @@ class DatabaseSeedingService { Future createTables() async { _log.info('Starting database schema creation...'); try { - await _connection.transaction((ctx) async { + // Manually handle the transaction with BEGIN/COMMIT/ROLLBACK. + await _connection.execute('BEGIN'); + + try { _log.fine('Creating "users" table...'); - await ctx.execute(''' + // All statements are executed on the main connection within the + // manual transaction. + await _connection.execute(''' CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE, @@ -41,7 +46,7 @@ class DatabaseSeedingService { '''); _log.fine('Creating "app_config" table...'); - await ctx.execute(''' + await _connection.execute(''' CREATE TABLE IF NOT EXISTS app_config ( id TEXT PRIMARY KEY, user_preference_limits JSONB NOT NULL, @@ -51,7 +56,7 @@ class DatabaseSeedingService { '''); _log.fine('Creating "categories" table...'); - await ctx.execute(''' + await _connection.execute(''' CREATE TABLE IF NOT EXISTS categories ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -61,7 +66,7 @@ class DatabaseSeedingService { '''); _log.fine('Creating "sources" table...'); - await ctx.execute(''' + await _connection.execute(''' CREATE TABLE IF NOT EXISTS sources ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -71,7 +76,7 @@ class DatabaseSeedingService { '''); _log.fine('Creating "countries" table...'); - await ctx.execute(''' + await _connection.execute(''' CREATE TABLE IF NOT EXISTS countries ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -82,7 +87,7 @@ class DatabaseSeedingService { '''); _log.fine('Creating "headlines" table...'); - await ctx.execute(''' + await _connection.execute(''' CREATE TABLE IF NOT EXISTS headlines ( id TEXT PRIMARY KEY, title TEXT NOT NULL, @@ -99,7 +104,7 @@ class DatabaseSeedingService { '''); _log.fine('Creating "user_app_settings" table...'); - await ctx.execute(''' + await _connection.execute(''' CREATE TABLE IF NOT EXISTS user_app_settings ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -111,7 +116,7 @@ class DatabaseSeedingService { '''); _log.fine('Creating "user_content_preferences" table...'); - await ctx.execute(''' + await _connection.execute(''' CREATE TABLE IF NOT EXISTS user_content_preferences ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -123,7 +128,13 @@ class DatabaseSeedingService { updated_at TIMESTAMPTZ ); '''); - }); + + await _connection.execute('COMMIT'); + } catch (e) { + // If any query inside the transaction fails, roll back. + await _connection.execute('ROLLBACK'); + rethrow; // Re-throw the original error + } _log.info('Database schema creation completed successfully.'); } on Object catch (e, st) { _log.severe( @@ -137,4 +148,86 @@ class DatabaseSeedingService { ); } } + + /// Seeds the database with global fixture data (categories, sources, etc.). + /// + /// This method is idempotent, using `ON CONFLICT DO NOTHING` to prevent + /// errors if the data already exists. It runs within a single transaction. + Future seedGlobalFixtureData() async { + _log.info('Seeding global fixture data...'); + try { + await _connection.execute('BEGIN'); + try { + // Seed Categories + _log.fine('Seeding categories...'); + for (final data in categoriesFixturesData) { + final category = Category.fromJson(data); + await _connection.execute( + Sql.named( + 'INSERT INTO categories (id, name) VALUES (@id, @name) ' + 'ON CONFLICT (id) DO NOTHING', + ), + parameters: category.toJson(), + ); + } + + // Seed Sources + _log.fine('Seeding sources...'); + for (final data in sourcesFixturesData) { + final source = Source.fromJson(data); + await _connection.execute( + Sql.named( + 'INSERT INTO sources (id, name) VALUES (@id, @name) ' + 'ON CONFLICT (id) DO NOTHING', + ), + parameters: source.toJson(), + ); + } + + // Seed Countries + _log.fine('Seeding countries...'); + for (final data in countriesFixturesData) { + final country = Country.fromJson(data); + await _connection.execute( + Sql.named( + 'INSERT INTO countries (id, name, code) ' + 'VALUES (@id, @name, @code) ON CONFLICT (id) DO NOTHING', + ), + parameters: country.toJson(), + ); + } + + // Seed Headlines + _log.fine('Seeding headlines...'); + for (final data in headlinesFixturesData) { + final headline = Headline.fromJson(data); + await _connection.execute( + Sql.named( + 'INSERT INTO headlines (id, title, source_id, category_id, ' + 'image_url, url, published_at, description, content) ' + 'VALUES (@id, @title, @sourceId, @categoryId, @imageUrl, @url, ' + '@publishedAt, @description, @content) ' + 'ON CONFLICT (id) DO NOTHING', + ), + parameters: headline.toJson(), + ); + } + + await _connection.execute('COMMIT'); + _log.info('Global fixture data seeding completed successfully.'); + } catch (e) { + await _connection.execute('ROLLBACK'); + rethrow; + } + } on Object catch (e, st) { + _log.severe( + 'An error occurred during global fixture data seeding.', + e, + st, + ); + throw OperationFailedException( + 'Failed to seed global fixture data: $e', + ); + } + } } From 31bddd246496f3b834d9f3a6d9e161d7b3b8f02f Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:47:46 +0100 Subject: [PATCH 13/18] feat(data): implement seeding for admin user and app config Adds the `seedInitialAdminAndConfig` method to `DatabaseSeedingService`. This method inserts the default `AppConfig` and the initial admin `User` from the shared fixtures into the database. The operation is idempotent, using `ON CONFLICT DO NOTHING` to prevent overwriting existing data on subsequent server starts. --- .../services/database_seeding_service.dart | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index e69aae8..1310724 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -230,4 +230,62 @@ class DatabaseSeedingService { ); } } + + /// Seeds the database with the initial AppConfig and the default admin user. + /// + /// This method is idempotent, using `ON CONFLICT DO NOTHING` to prevent + /// errors if the data already exists. It runs within a single transaction. + Future seedInitialAdminAndConfig() async { + _log.info('Seeding initial AppConfig and admin user...'); + try { + await _connection.execute('BEGIN'); + try { + // Seed AppConfig + _log.fine('Seeding AppConfig...'); + final appConfig = AppConfig.fromJson(appConfigFixtureData); + await _connection.execute( + Sql.named( + 'INSERT INTO app_config (id, user_preference_limits) ' + 'VALUES (@id, @user_preference_limits) ' + 'ON CONFLICT (id) DO NOTHING', + ), + parameters: appConfig.toJson(), + ); + + // Seed Admin User + _log.fine('Seeding admin user...'); + // Find the admin user in the fixture data. + final adminUserData = usersFixturesData.firstWhere( + (user) => (user['roles'] as List).contains(UserRoles.admin), + orElse: () => throw StateError('Admin user not found in fixtures.'), + ); + final adminUser = User.fromJson(adminUserData); + await _connection.execute( + Sql.named( + 'INSERT INTO users (id, email, roles) ' + 'VALUES (@id, @email, @roles) ' + 'ON CONFLICT (id) DO NOTHING', + ), + parameters: adminUser.toJson(), + ); + + await _connection.execute('COMMIT'); + _log.info( + 'Initial AppConfig and admin user seeding completed successfully.', + ); + } catch (e) { + await _connection.execute('ROLLBACK'); + rethrow; + } + } on Object catch (e, st) { + _log.severe( + 'An error occurred during initial admin/config seeding.', + e, + st, + ); + throw OperationFailedException( + 'Failed to seed initial admin/config data: $e', + ); + } + } } From 35fa9314bcdaced8d68e1df6068254273520ecd5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:50:48 +0100 Subject: [PATCH 14/18] fix(data): correct admin user seeding logic Fixes a bug in `DatabaseSeedingService` where the code was incorrectly attempting to access properties on a `User` object as if it were a `Map`. The logic now correctly accesses the `.roles` property and uses the `User` object from the fixture list directly. --- lib/src/services/database_seeding_service.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 1310724..d0b2eaf 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -255,11 +255,10 @@ class DatabaseSeedingService { // Seed Admin User _log.fine('Seeding admin user...'); // Find the admin user in the fixture data. - final adminUserData = usersFixturesData.firstWhere( - (user) => (user['roles'] as List).contains(UserRoles.admin), + final adminUser = usersFixturesData.firstWhere( + (user) => user.roles.contains(UserRoles.admin), orElse: () => throw StateError('Admin user not found in fixtures.'), ); - final adminUser = User.fromJson(adminUserData); await _connection.execute( Sql.named( 'INSERT INTO users (id, email, roles) ' From dc758e1923d4f8aafc6199999e0a72d8bdab0d5c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:54:00 +0100 Subject: [PATCH 15/18] fix(data): seed default settings and preferences for admin user Updates the `seedInitialAdminAndConfig` method to create and insert the default `UserAppSettings` and `UserContentPreferences` records for the seeded admin user. This fixes a bug where the admin user would lack these necessary associated records, ensuring consistency with the user creation logic in `AuthService`. --- .../services/database_seeding_service.dart | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index d0b2eaf..7dc95b9 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -268,6 +268,37 @@ class DatabaseSeedingService { parameters: adminUser.toJson(), ); + // Seed default settings and preferences for the admin user. + final adminSettings = UserAppSettings(id: adminUser.id); + final adminPreferences = UserContentPreferences(id: adminUser.id); + + await _connection.execute( + Sql.named( + 'INSERT INTO user_app_settings (id, user_id, ' + 'display_settings, language) ' + 'VALUES (@id, @user_id, @display_settings, @language) ' + 'ON CONFLICT (id) DO NOTHING', + ), + parameters: { + ...adminSettings.toJson(), + 'user_id': adminUser.id, + }, + ); + + await _connection.execute( + Sql.named( + 'INSERT INTO user_content_preferences (id, user_id, ' + 'followed_categories, followed_sources, followed_countries, ' + 'saved_headlines) VALUES (@id, @user_id, @followed_categories, ' + '@followed_sources, @followed_countries, @saved_headlines) ' + 'ON CONFLICT (id) DO NOTHING', + ), + parameters: { + ...adminPreferences.toJson(), + 'user_id': adminUser.id, + }, + ); + await _connection.execute('COMMIT'); _log.info( 'Initial AppConfig and admin user seeding completed successfully.', From 745ab9dded0ddcffda2c98024acb00e57cceeb34 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 10:57:40 +0100 Subject: [PATCH 16/18] feat(core): integrate database seeding into server startup Updates `server.dart` to instantiate and run the `DatabaseSeedingService` after the database connection is established. This ensures that tables are created and initial data (global fixtures, admin user, app config) is seeded on every server start. The idempotent nature of the seeding operations makes this process safe and reliable for both development and first-time production deployments. --- lib/src/config/server.dart | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart index 447c7d1..67a64a9 100644 --- a/lib/src/config/server.dart +++ b/lib/src/config/server.dart @@ -8,6 +8,7 @@ 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/default_user_preference_limit_service.dart'; import 'package:ht_api/src/services/jwt_auth_token_service.dart'; +import 'package:ht_api/src/services/database_seeding_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'; @@ -92,7 +93,17 @@ Future run(Handler handler, InternetAddress ip, int port) async { ); _log.info('PostgreSQL database connection established.'); - // 3. Initialize Repositories + // 3. Initialize and run database seeding + // This runs on every startup. The operations are idempotent (`IF NOT EXISTS`, + // `ON CONFLICT DO NOTHING`), so it's safe to run every time. This ensures + // the database is always in a valid state, especially for first-time setup + // in any environment. + final seedingService = DatabaseSeedingService(connection: _connection, log: _log); + await seedingService.createTables(); + await seedingService.seedGlobalFixtureData(); + await seedingService.seedInitialAdminAndConfig(); + + // 4. Initialize Repositories final headlineRepository = _createRepository( tableName: 'headlines', fromJson: Headline.fromJson, @@ -135,7 +146,7 @@ Future run(Handler handler, InternetAddress ip, int port) async { toJson: (c) => c.toJson(), ); - // 4. Initialize Services + // 5. Initialize Services const uuid = Uuid(); const emailRepository = HtEmailRepository( emailClient: HtEmailInMemoryClient(), @@ -167,7 +178,7 @@ Future run(Handler handler, InternetAddress ip, int port) async { appConfigRepository: appConfigRepository, ); - // 5. Create the main handler with all dependencies provided + // 6. Create the main handler with all dependencies provided final finalHandler = handler // Foundational utilities .use(provider((_) => uuid)) @@ -206,7 +217,7 @@ Future run(Handler handler, InternetAddress ip, int port) async { ), ); - // 6. Start the server + // 7. Start the server final server = await serve( finalHandler, ip, @@ -214,7 +225,7 @@ Future run(Handler handler, InternetAddress ip, int port) async { ); _log.info('Server listening on port ${server.port}'); - // 7. Handle graceful shutdown + // 8. Handle graceful shutdown ProcessSignal.sigint.watch().listen((_) async { _log.info('Received SIGINT. Shutting down...'); await _connection.close(); From 68a2fb529b39adc169dc801a0f721c646d22c7c9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 11:11:28 +0100 Subject: [PATCH 17/18] docs: update README with postgresql setup instructions Enhances the README.md to reflect the new PostgreSQL database requirement. - Adds PostgreSQL to the prerequisites. - Adds a new "Configuration" section explaining the `DATABASE_URL` environment variable. - Updates the "Run the development server" description to remove references to in-memory repositories and mention the new database seeding process. --- README.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 45cb4ce..4c0f833 100644 --- a/README.md +++ b/README.md @@ -66,23 +66,38 @@ for more details. 1. **Prerequisites:** * Dart SDK (`>=3.0.0`) + * PostgreSQL (`>=14.0` recommended) * Dart Frog CLI (`dart pub global activate dart_frog_cli`) -2. **Clone the repository:** + +2. **Configuration:** + Before running the server, you must configure the database connection by + setting the `DATABASE_URL` environment variable. + + Create a `.env` file in the root of the project or export the variable in + your shell: + ``` + DATABASE_URL="postgres://user:password@localhost:5432/ht_api_db" + ``` + +3. **Clone the repository:** ```bash git clone https://github.com/headlines-toolkit/ht-api.git cd ht-api ``` -3. **Get dependencies:** +4. **Get dependencies:** ```bash dart pub get ``` -4. **Run the development server:** +5. **Run the development server:** ```bash dart_frog dev ``` - The API will typically be available at `http://localhost:8080`. Fixture data - from `lib/src/fixtures/` will be loaded into the in-memory repositories on - startup. + The API will typically be available at `http://localhost:8080`. On the + first startup, the server will connect to your PostgreSQL database, create the + necessary tables, and seed them with initial fixture data. This process is + non-destructive; it uses `CREATE TABLE IF NOT EXISTS` and `INSERT ... ON + 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, From cccd890814d13d6dc793bf565fd7053f33368f62 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 11:12:47 +0100 Subject: [PATCH 18/18] lint: misc --- lib/src/config/server.dart | 39 +++++++++---------- lib/src/registry/model_registry.dart | 2 + lib/src/services/auth_service.dart | 16 ++++---- .../services/database_seeding_service.dart | 22 +++-------- routes/_middleware.dart | 19 ++++----- 5 files changed, 40 insertions(+), 58 deletions(-) diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart index 67a64a9..8853b36 100644 --- a/lib/src/config/server.dart +++ b/lib/src/config/server.dart @@ -1,4 +1,4 @@ - import 'dart:io'; +import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/config/environment_config.dart'; @@ -6,9 +6,9 @@ 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/database_seeding_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'; @@ -23,20 +23,20 @@ import 'package:postgres/postgres.dart'; import 'package:uuid/uuid.dart'; /// Global logger instance. - final _log = Logger('ht_api'); +final _log = Logger('ht_api'); /// Global PostgreSQL connection instance. - late final Connection _connection; +late final Connection _connection; /// Creates a data repository for a given type [T]. /// /// This helper function centralizes the creation of repositories, /// ensuring they all use the same database connection and logger. - HtDataRepository _createRepository({ +HtDataRepository _createRepository({ required String tableName, required FromJson fromJson, required ToJson toJson, - }) { +}) { return HtDataRepository( dataClient: HtDataPostgresClient( connection: _connection, @@ -46,7 +46,7 @@ import 'package:uuid/uuid.dart'; log: _log, ), ); - } +} /// The main entry point for the server. /// @@ -98,7 +98,10 @@ Future run(Handler handler, InternetAddress ip, int port) async { // `ON CONFLICT DO NOTHING`), so it's safe to run every time. This ensures // the database is always in a valid state, especially for first-time setup // in any environment. - final seedingService = DatabaseSeedingService(connection: _connection, log: _log); + final seedingService = DatabaseSeedingService( + connection: _connection, + log: _log, + ); await seedingService.createTables(); await seedingService.seedGlobalFixtureData(); await seedingService.seedInitialAdminAndConfig(); @@ -136,10 +139,10 @@ Future run(Handler handler, InternetAddress ip, int port) async { ); final userContentPreferencesRepository = _createRepository( - tableName: 'user_content_preferences', - fromJson: UserContentPreferences.fromJson, - toJson: (p) => p.toJson(), - ); + tableName: 'user_content_preferences', + fromJson: UserContentPreferences.fromJson, + toJson: (p) => p.toJson(), + ); final appConfigRepository = _createRepository( tableName: 'app_config', fromJson: AppConfig.fromJson, @@ -212,17 +215,11 @@ Future run(Handler handler, InternetAddress ip, int port) async { .use(provider((_) => dashboardSummaryService)) .use(provider((_) => permissionService)) .use( - provider( - (_) => userPreferenceLimitService, - ), + provider((_) => userPreferenceLimitService), ); // 7. Start the server - final server = await serve( - finalHandler, - ip, - port, - ); + final server = await serve(finalHandler, ip, port); _log.info('Server listening on port ${server.port}'); // 8. Handle graceful shutdown @@ -236,4 +233,4 @@ Future run(Handler handler, InternetAddress ip, int port) async { }); return server; - } \ No newline at end of file +} diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart index 3d3dda4..9ef2cdc 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -1,3 +1,5 @@ +// ignore_for_file: comment_references + import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/rbac/permissions.dart'; import 'package:ht_data_client/ht_data_client.dart'; diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 186ec8a..34e2e21 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -171,11 +171,7 @@ class AuthService { // Admin users must be provisioned out-of-band (e.g., via fixtures). final roles = [UserRoles.standardUser]; - user = User( - id: _uuid.v4(), - email: email, - roles: roles, - ); + user = User(id: _uuid.v4(), email: email, roles: roles); user = await _userRepository.create(item: user); print('Created new user: ${user.id} with roles: ${user.roles}'); @@ -197,7 +193,9 @@ class AuthService { } } on HtHttpException catch (e) { print('Error finding/creating user for $email: $e'); - throw const OperationFailedException('Failed to find or create user account.'); + throw const OperationFailedException( + 'Failed to find or create user account.', + ); } catch (e) { print('Unexpected error during user lookup/creation for $email: $e'); throw const OperationFailedException('Failed to process user account.'); @@ -213,7 +211,7 @@ class AuthService { throw const OperationFailedException( 'Failed to generate authentication token.', ); - } + } } /// Performs anonymous sign-in. @@ -227,7 +225,7 @@ class AuthService { try { user = User( id: _uuid.v4(), // Generate new ID - roles: [UserRoles.guestUser], // Anonymous users are guest users + roles: const [UserRoles.guestUser], // Anonymous users are guest users email: null, // Anonymous users don't have an email initially ); user = await _userRepository.create(item: user); @@ -426,7 +424,7 @@ class AuthService { final updatedUser = User( id: anonymousUser.id, // Preserve original ID email: linkedEmail, - roles: [UserRoles.standardUser], // Now a permanent standard user + roles: const [UserRoles.standardUser], // Now a permanent standard user ); final permanentUser = await _userRepository.update( id: updatedUser.id, diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 7dc95b9..ab6080d 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -1,6 +1,6 @@ +import 'package:ht_shared/ht_shared.dart'; import 'package:logging/logging.dart'; import 'package:postgres/postgres.dart'; -import 'package:ht_shared/ht_shared.dart'; /// {@template database_seeding_service} /// A service responsible for initializing the database schema and seeding it @@ -137,11 +137,7 @@ class DatabaseSeedingService { } _log.info('Database schema creation completed successfully.'); } on Object catch (e, st) { - _log.severe( - 'An error occurred during database schema creation.', - e, - st, - ); + _log.severe('An error occurred during database schema creation.', e, st); // Propagate as a standard exception for the server to handle. throw OperationFailedException( 'Failed to initialize database schema: $e', @@ -225,9 +221,7 @@ class DatabaseSeedingService { e, st, ); - throw OperationFailedException( - 'Failed to seed global fixture data: $e', - ); + throw OperationFailedException('Failed to seed global fixture data: $e'); } } @@ -279,10 +273,7 @@ class DatabaseSeedingService { 'VALUES (@id, @user_id, @display_settings, @language) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: { - ...adminSettings.toJson(), - 'user_id': adminUser.id, - }, + parameters: {...adminSettings.toJson(), 'user_id': adminUser.id}, ); await _connection.execute( @@ -293,10 +284,7 @@ class DatabaseSeedingService { '@followed_sources, @followed_countries, @saved_headlines) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: { - ...adminPreferences.toJson(), - 'user_id': adminUser.id, - }, + parameters: {...adminPreferences.toJson(), 'user_id': adminUser.id}, ); await _connection.execute('COMMIT'); diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 6d344a8..56ccd46 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -1,6 +1,5 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/middlewares/error_handler.dart'; -import 'package:ht_api/src/middlewares/request_logger.dart'; import 'package:uuid/uuid.dart'; // --- Request ID Wrapper --- @@ -54,16 +53,14 @@ Handler middleware(Handler handler) { // 3. Error Handler: Catches all errors and formats them into a standard // JSON response. return handler - .use( - (innerHandler) { - return (context) { - // Read the singleton Uuid instance provided from server.dart. - final uuid = context.read(); - final requestId = RequestId(uuid.v4()); - return innerHandler(context.provide(() => requestId)); - }; - }, - ) + .use((innerHandler) { + return (context) { + // Read the singleton Uuid instance provided from server.dart. + final uuid = context.read(); + final requestId = RequestId(uuid.v4()); + return innerHandler(context.provide(() => requestId)); + }; + }) .use(requestLogger()) .use(errorHandler()); }