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, 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'; +} diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart new file mode 100644 index 0000000..8853b36 --- /dev/null +++ b/lib/src/config/server.dart @@ -0,0 +1,236 @@ +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/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 new file mode 100644 index 0000000..ab6080d --- /dev/null +++ b/lib/src/services/database_seeding_service.dart @@ -0,0 +1,309 @@ +import 'package:ht_shared/ht_shared.dart'; +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; + + /// 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 { + // Manually handle the transaction with BEGIN/COMMIT/ROLLBACK. + await _connection.execute('BEGIN'); + + try { + _log.fine('Creating "users" table...'); + // 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, + roles JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_engagement_shown_at TIMESTAMPTZ + ); + '''); + + _log.fine('Creating "app_config" table...'); + await _connection.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 _connection.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 _connection.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 _connection.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 _connection.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 _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, + 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 _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, + 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 + ); + '''); + + 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('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', + ); + } + } + + /// 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'); + } + } + + /// 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 adminUser = usersFixturesData.firstWhere( + (user) => user.roles.contains(UserRoles.admin), + orElse: () => throw StateError('Admin user not found in fixtures.'), + ); + await _connection.execute( + Sql.named( + 'INSERT INTO users (id, email, roles) ' + 'VALUES (@id, @email, @roles) ' + 'ON CONFLICT (id) DO NOTHING', + ), + 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.', + ); + } 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', + ); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 48c9d55..039d10b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,9 +12,9 @@ dependencies: ht_data_client: git: url: https://github.com/headlines-toolkit/ht-data-client.git - ht_data_inmemory: + ht_data_postgres: git: - url: https://github.com/headlines-toolkit/ht-data-inmemory.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 +34,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 diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 15ae1ef..56ccd46 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,23 @@ 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 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 - // 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 + // Read the singleton Uuid instance provided from server.dart. + final uuid = context.read(); + final requestId = RequestId(uuid.v4()); 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), - ) - // --- 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()); }