diff --git a/README.md b/README.md index 4c0f833..1bc895d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart new file mode 100644 index 0000000..ee2d104 --- /dev/null +++ b/lib/src/config/app_dependencies.dart @@ -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(); + + // --- Repositories --- + late final HtDataRepository headlineRepository; + late final HtDataRepository categoryRepository; + late final HtDataRepository sourceRepository; + late final HtDataRepository countryRepository; + late final HtDataRepository userRepository; + late final HtDataRepository userAppSettingsRepository; + late final HtDataRepository + userContentPreferencesRepository; + late final HtDataRepository 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 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 _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, 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 _createRepository( + Connection connection, + String tableName, + FromJson fromJson, + ToJson toJson, + ) { + return HtDataRepository( + dataClient: HtDataPostgresClient( + connection: connection, + tableName: tableName, + fromJson: fromJson, + toJson: toJson, + log: _log, + ), + ); + } +} diff --git a/lib/src/config/database_connection.dart b/lib/src/config/database_connection.dart new file mode 100644 index 0000000..36e85c8 --- /dev/null +++ b/lib/src/config/database_connection.dart @@ -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(); + + /// 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 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 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); + } +} diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index a7172fc..5570124 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -1,4 +1,6 @@ import 'dart:io'; +import 'package:logging/logging.dart'; +import 'package:dotenv/dotenv.dart'; /// {@template environment_config} /// A utility class for accessing environment variables. @@ -8,6 +10,12 @@ import 'dart:io'; /// connection strings are managed outside of the source code. /// {@endtemplate} abstract final class EnvironmentConfig { + static final _log = Logger('EnvironmentConfig'); + + // The DotEnv instance that loads the .env file and platform variables. + // It's initialized once and reused. + static final _env = DotEnv(includePlatformEnvironment: true)..load(); + /// Retrieves the PostgreSQL database connection URI from the environment. /// /// The value is read from the `DATABASE_URL` environment variable. @@ -15,8 +23,14 @@ abstract final class EnvironmentConfig { /// 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']; + final dbUrl = _env['DATABASE_URL']; if (dbUrl == null || dbUrl.isEmpty) { + _log.severe( + 'DATABASE_URL not found. Dumping available environment variables:', + ); + _env.map.forEach((key, value) { + _log.severe(' - $key: $value'); + }); throw StateError( 'FATAL: DATABASE_URL environment variable is not set. ' 'The application cannot start without a database connection.', @@ -29,5 +43,5 @@ abstract final class EnvironmentConfig { /// /// 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'; + static String get environment => _env['ENV'] ?? 'production'; } diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart deleted file mode 100644 index 8853b36..0000000 --- a/lib/src/config/server.dart +++ /dev/null @@ -1,236 +0,0 @@ -import 'dart:io'; - -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/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'; - -/// Global logger instance. -final _log = Logger('ht_api'); - -/// Global PostgreSQL connection instance. -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. -/// -/// 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...'); - 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( - 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. 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, - 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(), - ); - - // 5. 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, - ); - - // 6. Create the main handler with all dependencies provided - final finalHandler = handler - // Foundational utilities - .use(provider((_) => uuid)) - // Repositories - .use(provider>((_) => headlineRepository)) - .use(provider>((_) => categoryRepository)) - .use(provider>((_) => sourceRepository)) - .use(provider>((_) => countryRepository)) - .use(provider>((_) => userRepository)) - .use( - provider>( - (_) => userAppSettingsRepository, - ), - ) - .use( - provider>( - (_) => userContentPreferencesRepository, - ), - ) - .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), - ); - - // 7. Start the server - final server = await serve(finalHandler, ip, port); - _log.info('Server listening on port ${server.port}'); - - // 8. 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; -} diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index ab6080d..6ba301d 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:ht_shared/ht_shared.dart'; import 'package:logging/logging.dart'; import 'package:postgres/postgres.dart'; @@ -59,7 +60,11 @@ class DatabaseSeedingService { await _connection.execute(''' CREATE TABLE IF NOT EXISTS categories ( id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + icon_url TEXT, + status TEXT, + type TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); @@ -69,7 +74,14 @@ class DatabaseSeedingService { await _connection.execute(''' CREATE TABLE IF NOT EXISTS sources ( id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + url TEXT, + language TEXT, + status TEXT, + type TEXT, + source_type TEXT, + headquarters_country_id TEXT REFERENCES countries(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); @@ -79,8 +91,11 @@ class DatabaseSeedingService { await _connection.execute(''' CREATE TABLE IF NOT EXISTS countries ( id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + iso_code TEXT NOT NULL UNIQUE, + flag_url TEXT NOT NULL, + status TEXT, + type TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); @@ -93,13 +108,14 @@ class DatabaseSeedingService { 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, + image_url TEXT, + url TEXT, + published_at TIMESTAMPTZ, description TEXT, - content TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ + updated_at TIMESTAMPTZ, + status TEXT, + type TEXT ); '''); @@ -108,8 +124,11 @@ class DatabaseSeedingService { 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, + display_settings JSONB NOT NULL, -- Nested object, stored as JSON + language TEXT NOT NULL, -- Simple string, stored as TEXT + feed_preferences JSONB NOT NULL, + engagement_shown_counts JSONB NOT NULL, + engagement_last_shown_timestamps JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); @@ -158,38 +177,80 @@ class DatabaseSeedingService { _log.fine('Seeding categories...'); for (final data in categoriesFixturesData) { final category = Category.fromJson(data); + final params = category.toJson(); + + // Ensure optional fields exist for the postgres driver. + // The driver requires all named parameters to be present in the map, + // even if the value is null. The model's `toJson` with + // `includeIfNull: false` will omit keys for null fields. + params.putIfAbsent('description', () => null); + params.putIfAbsent('icon_url', () => null); + params.putIfAbsent('updated_at', () => null); + await _connection.execute( Sql.named( - 'INSERT INTO categories (id, name) VALUES (@id, @name) ' + 'INSERT INTO categories (id, name, description, icon_url, ' + 'status, type, created_at, updated_at) VALUES (@id, @name, @description, ' + '@icon_url, @status, @type, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: category.toJson(), + parameters: params, ); } - // Seed Sources - _log.fine('Seeding sources...'); - for (final data in sourcesFixturesData) { - final source = Source.fromJson(data); + // Seed Countries (must be done before sources and headlines) + _log.fine('Seeding countries...'); + for (final data in countriesFixturesData) { + final country = Country.fromJson(data); + final params = country.toJson(); + + // Ensure optional fields exist for the postgres driver. + params.putIfAbsent('updated_at', () => null); + await _connection.execute( Sql.named( - 'INSERT INTO sources (id, name) VALUES (@id, @name) ' + 'INSERT INTO countries (id, name, iso_code, flag_url, status, ' + 'type, created_at, updated_at) VALUES (@id, @name, @iso_code, ' + '@flag_url, @status, @type, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: source.toJson(), + parameters: params, ); } - // Seed Countries - _log.fine('Seeding countries...'); - for (final data in countriesFixturesData) { - final country = Country.fromJson(data); + // Seed Sources + _log.fine('Seeding sources...'); + for (final data in sourcesFixturesData) { + final source = Source.fromJson(data); + final params = source.toJson(); + + // The `headquarters` field in the model is a nested `Country` + // object. We extract its ID to store in the + // `headquarters_country_id` column and then remove the original + // nested object from the parameters to avoid a "superfluous + // variable" error. + params['headquarters_country_id'] = source.headquarters?.id; + params.remove('headquarters'); + + // Ensure optional fields exist for the postgres driver. + params.putIfAbsent('description', () => null); + params.putIfAbsent('url', () => null); + params.putIfAbsent('language', () => null); + params.putIfAbsent('source_type', () => null); + params.putIfAbsent('status', () => null); + params.putIfAbsent('type', () => null); + params.putIfAbsent('updated_at', () => null); + await _connection.execute( Sql.named( - 'INSERT INTO countries (id, name, code) ' - 'VALUES (@id, @name, @code) ON CONFLICT (id) DO NOTHING', + 'INSERT INTO sources (id, name, description, url, language, ' + 'status, type, source_type, headquarters_country_id, ' + 'created_at, updated_at) VALUES (@id, @name, @description, ' + '@url, @language, @status, @type, @source_type, ' + '@headquarters_country_id, @created_at, @updated_at) ' + 'ON CONFLICT (id) DO NOTHING', ), - parameters: country.toJson(), + parameters: params, ); } @@ -197,15 +258,41 @@ class DatabaseSeedingService { _log.fine('Seeding headlines...'); for (final data in headlinesFixturesData) { final headline = Headline.fromJson(data); + final params = headline.toJson(); + + // The `source_id` and `category_id` columns are NOT NULL. If a fixture + // is missing the nested source or category object, we cannot proceed. + if (headline.source == null || headline.category == null) { + _log.warning( + 'Skipping headline fixture with missing source or category ID: ' + '${headline.title}', + ); + continue; + } + + // Extract IDs from nested objects and remove the objects to match schema. + params['source_id'] = headline.source!.id; + params['category_id'] = headline.category!.id; + params.remove('source'); + params.remove('category'); + + // Ensure optional fields exist for the postgres driver. + params.putIfAbsent('description', () => null); + params.putIfAbsent('updated_at', () => null); + params.putIfAbsent('image_url', () => null); + params.putIfAbsent('url', () => null); + params.putIfAbsent('published_at', () => null); + 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) ' + 'image_url, url, published_at, description, status, ' + 'type, created_at, updated_at) VALUES (@id, @title, @source_id, ' + '@category_id, @image_url, @url, @published_at, @description, ' + '@status, @type, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: headline.toJson(), + parameters: params, ); } @@ -236,14 +323,20 @@ class DatabaseSeedingService { try { // Seed AppConfig _log.fine('Seeding AppConfig...'); - final appConfig = AppConfig.fromJson(appConfigFixtureData); + final appConfig = AppConfig.fromJson(appConfigFixtureData); + // The `app_config` table only has `id` and `user_preference_limits`. + // We must provide an explicit map to avoid a "superfluous variables" + // error from the postgres driver. 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(), + parameters: { + 'id': appConfig.id, + 'user_preference_limits': appConfig.userPreferenceLimits.toJson(), + }, ); // Seed Admin User @@ -253,13 +346,19 @@ class DatabaseSeedingService { (user) => user.roles.contains(UserRoles.admin), orElse: () => throw StateError('Admin user not found in fixtures.'), ); + // The `users` table only has `id`, `email`, and `roles`. We must + // provide an explicit map to avoid a "superfluous variables" error. await _connection.execute( Sql.named( 'INSERT INTO users (id, email, roles) ' 'VALUES (@id, @email, @roles) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: adminUser.toJson(), + parameters: { + 'id': adminUser.id, + 'email': adminUser.email, + 'roles': jsonEncode(adminUser.roles), + }, ); // Seed default settings and preferences for the admin user. @@ -268,12 +367,15 @@ class DatabaseSeedingService { await _connection.execute( Sql.named( - 'INSERT INTO user_app_settings (id, user_id, ' - 'display_settings, language) ' - 'VALUES (@id, @user_id, @display_settings, @language) ' + 'INSERT INTO user_app_settings (id, user_id, display_settings, ' + 'language, feed_preferences, engagement_shown_counts, ' + 'engagement_last_shown_timestamps) VALUES (@id, @user_id, ' + '@display_settings, @language, @feed_preferences, ' + '@engagement_shown_counts, @engagement_last_shown_timestamps) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: {...adminSettings.toJson(), 'user_id': adminUser.id}, + parameters: adminSettings.toJson() + ..['user_id'] = adminUser.id, ); await _connection.execute( @@ -284,7 +386,10 @@ class DatabaseSeedingService { '@followed_sources, @followed_countries, @saved_headlines) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: {...adminPreferences.toJson(), 'user_id': adminUser.id}, + // Use toJson() to correctly serialize the lists of complex objects + // into a format the database driver can handle for JSONB columns. + parameters: adminPreferences.toJson() + ..['user_id'] = adminUser.id, ); await _connection.execute('COMMIT'); diff --git a/pubspec.yaml b/pubspec.yaml index b1f598c..56fade7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: dart_frog: ^1.1.0 dart_jsonwebtoken: ^3.2.0 + dotenv: ^4.2.0 ht_data_client: git: url: https://github.com/headlines-toolkit/ht-data-client.git diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 56ccd46..f9fb006 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -1,5 +1,18 @@ import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_api/src/config/app_dependencies.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/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_repository/ht_data_repository.dart'; +import 'package:ht_email_repository/ht_email_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; // --- Request ID Wrapper --- @@ -45,22 +58,85 @@ class RequestId { } // --- Middleware Definition --- +final _log = Logger('RootMiddleware'); + +// A flag to ensure the logger is only configured once for the application's +// entire lifecycle. +bool _loggerConfigured = false; + Handler middleware(Handler handler) { - // 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. + // This is the root middleware for the entire API. It's responsible for + // providing all shared dependencies to the request context. + // The order of `.use()` calls is important: the last one in the chain + // runs first. + + // This check ensures that the logger is configured only once. + if (!_loggerConfigured) { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + // ignore: avoid_print + print( + '${record.level.name}: ${record.time}: ${record.loggerName}: ' + '${record.message}', + ); + }); + _loggerConfigured = true; + } + return handler + // --- Core Middleware --- + // These run after all dependencies have been provided. + .use(errorHandler()) + .use(requestLogger()) + // --- Request ID Provider --- + // This middleware provides a unique ID for each request for tracing. + // It depends on the Uuid provider, so it must come after it. .use((innerHandler) { return (context) { - // Read the singleton Uuid instance provided from server.dart. + _log.info('[REQ_LIFECYCLE] Request received. Generating RequestId...'); final uuid = context.read(); final requestId = RequestId(uuid.v4()); + _log.info('[REQ_LIFECYCLE] RequestId generated: ${requestId.id}'); return innerHandler(context.provide(() => requestId)); }; }) - .use(requestLogger()) - .use(errorHandler()); + // --- Dependency Provider --- + // This is the outermost middleware. It runs once per request, before any + // other middleware. It's responsible for initializing and providing all + // dependencies for the request. + .use((handler) { + return (context) async { + // 1. Ensure all dependencies are initialized (idempotent). + _log.info('Ensuring all application dependencies are initialized...'); + await AppDependencies.instance.init(); + _log.info('Dependencies are ready.'); + + // 2. Provide all dependencies to the inner handler. + final deps = AppDependencies.instance; + return handler + .use(provider((_) => modelRegistry)) + .use(provider((_) => const Uuid())) + .use(provider>((_) => deps.headlineRepository)) + .use(provider>((_) => deps.categoryRepository)) + .use(provider>((_) => deps.sourceRepository)) + .use(provider>((_) => deps.countryRepository)) + .use(provider>((_) => deps.userRepository)) + .use(provider>( + (_) => deps.userAppSettingsRepository)) + .use(provider>( + (_) => deps.userContentPreferencesRepository)) + .use(provider>((_) => deps.appConfigRepository)) + .use(provider((_) => deps.emailRepository)) + .use(provider((_) => deps.tokenBlacklistService)) + .use(provider((_) => deps.authTokenService)) + .use(provider( + (_) => deps.verificationCodeStorageService)) + .use(provider((_) => deps.authService)) + .use(provider((_) => deps.dashboardSummaryService)) + .use(provider((_) => deps.permissionService)) + .use(provider( + (_) => deps.userPreferenceLimitService)) + .call(context); + }; + }); } diff --git a/routes/api/v1/_middleware.dart b/routes/api/v1/_middleware.dart index f7a57b1..e4f73e8 100644 --- a/routes/api/v1/_middleware.dart +++ b/routes/api/v1/_middleware.dart @@ -2,66 +2,74 @@ import 'dart:io' show Platform; // To read environment variables import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/middlewares/authentication_middleware.dart'; +import 'package:logging/logging.dart'; import 'package:shelf_cors_headers/shelf_cors_headers.dart' as shelf_cors; -Handler middleware(Handler handler) { - // This middleware applies providers and CORS handling to all routes - // under /api/v1/. +final _log = Logger('ApiV1Middleware'); - // --- CORS Configuration --- +/// Checks if the request's origin is allowed based on the environment. +/// +/// In production (when `CORS_ALLOWED_ORIGIN` is set), it performs a strict +/// check against the specified origin. +/// In development, it dynamically allows any `localhost` or `127.0.0.1` +/// origin to support the Flutter web dev server's random ports. +bool _isOriginAllowed(String origin) { + _log.info('[CORS] Checking origin: "$origin"'); final allowedOriginEnv = Platform.environment['CORS_ALLOWED_ORIGIN']; - String effectiveOrigin; if (allowedOriginEnv != null && allowedOriginEnv.isNotEmpty) { - effectiveOrigin = allowedOriginEnv; - print( - '[CORS Middleware] Using Access-Control-Allow-Origin from ' - 'CORS_ALLOWED_ORIGIN environment variable: "$effectiveOrigin"', - ); + // Production: strict check against the environment variable. + final isAllowed = origin == allowedOriginEnv; + _log.info('[CORS] Production check result: ${isAllowed ? 'ALLOWED' : 'DENIED'}'); + return isAllowed; } else { - // IMPORTANT: Default for local development ONLY if env var is not set. - // You MUST set CORS_ALLOWED_ORIGIN in production for security. - // This default allows credentials, so it cannot be '*'. - // Adjust 'http://localhost:3000' if your local Flutter web dev server - // typically runs on a different port. - effectiveOrigin = 'http://localhost:39155'; - print('------------------------------------------------------------------'); - print('WARNING: CORS_ALLOWED_ORIGIN environment variable is NOT SET.'); - print( - 'Defaulting Access-Control-Allow-Origin to: "$effectiveOrigin" ' - 'FOR DEVELOPMENT ONLY.', - ); - print( - 'For production, you MUST set the CORS_ALLOWED_ORIGIN environment ' - "variable to your Flutter web application's specific domain.", - ); - print('------------------------------------------------------------------'); + // Development: dynamically allow any localhost origin. + final isAllowed = origin.startsWith('http://localhost:') || + origin.startsWith('http://127.0.0.1:'); + _log.info('[CORS] Development check result: ${isAllowed ? 'ALLOWED' : 'DENIED'}'); + return isAllowed; } +} - final corsConfig = { - // Use the determined origin (from env var or development default) - shelf_cors.ACCESS_CONTROL_ALLOW_ORIGIN: effectiveOrigin, - // Crucial for authenticated APIs where the frontend sends credentials - // (e.g., Authorization header with fetch({ credentials: 'include' })) - shelf_cors.ACCESS_CONTROL_ALLOW_CREDENTIALS: 'true', - // Define allowed HTTP methods - shelf_cors.ACCESS_CONTROL_ALLOW_METHODS: 'GET, POST, PUT, DELETE, OPTIONS', - // Define allowed headers from the client - shelf_cors.ACCESS_CONTROL_ALLOW_HEADERS: - 'Origin, Content-Type, Authorization, Accept', - // Optional: How long the results of a preflight request can be cached - shelf_cors.ACCESS_CONTROL_MAX_AGE: '86400', // 24 hours - }; - - // Apply CORS middleware first. - // `fromShelfMiddleware` adapts the Shelf-based CORS middleware for Dart Frog. - var newHandler = handler.use( - fromShelfMiddleware(shelf_cors.corsHeaders(headers: corsConfig)), - ); +Handler middleware(Handler handler) { + // This middleware applies CORS and authentication to all routes under + // `/api/v1/`. The order of `.use()` is important: the last one in the + // chain runs first. + return handler + .use( + (handler) { + // This is a custom middleware to wrap the auth provider with logging. + final authMiddleware = authenticationProvider(); + final authHandler = authMiddleware(handler); - // Then apply the authenticationProvider. - // ignore: join_return_with_assignment - newHandler = newHandler.use(authenticationProvider()); + return (context) { + _log.info('[REQ_LIFECYCLE] Entering authentication middleware...'); + return authHandler(context); + }; + }, + ) + .use( + (handler) { + // This is a custom middleware to wrap the CORS provider with logging. + final corsMiddleware = fromShelfMiddleware( + shelf_cors.corsHeaders( + originChecker: _isOriginAllowed, + headers: { + shelf_cors.ACCESS_CONTROL_ALLOW_CREDENTIALS: 'true', + shelf_cors.ACCESS_CONTROL_ALLOW_METHODS: + 'GET, POST, PUT, DELETE, OPTIONS', + shelf_cors.ACCESS_CONTROL_ALLOW_HEADERS: + 'Origin, Content-Type, Authorization, Accept', + shelf_cors.ACCESS_CONTROL_MAX_AGE: '86400', + }, + ), + ); + final corsHandler = corsMiddleware(handler); - return newHandler; + return (context) { + _log.info('[REQ_LIFECYCLE] Entering CORS middleware...'); + return corsHandler(context); + }; + }, + ); }