diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 73827ce..ca747e5 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -9,6 +9,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/config/environm import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_token_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/country_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_preference_limit_service.dart'; @@ -61,6 +62,7 @@ class AppDependencies { late final EmailRepository emailRepository; // Services + late final CountryService countryService; late final TokenBlacklistService tokenBlacklistService; late final AuthTokenService authTokenService; late final VerificationCodeStorageService verificationCodeStorageService; @@ -179,7 +181,6 @@ class AppDependencies { dataClient: userContentPreferencesClient, ); remoteConfigRepository = DataRepository(dataClient: remoteConfigClient); - // Configure the HTTP client for SendGrid. // The HttpClient's AuthInterceptor will use the tokenProvider to add // the 'Authorization: Bearer ' header. @@ -238,6 +239,12 @@ class AppDependencies { connectionManager: _mongoDbConnectionManager, log: Logger('MongoDbRateLimitService'), ); + countryService = CountryService( + countryRepository: countryRepository, + headlineRepository: headlineRepository, + sourceRepository: sourceRepository, + logger: Logger('CountryService'), + ); _isInitialized = true; _log.info('Application dependencies initialized successfully.'); diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index caf2371..d90d356 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -2,6 +2,7 @@ import 'package:core/core.dart'; import 'package:dart_frog/dart_frog.dart'; import 'package:data_repository/data_repository.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/country_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; // --- Typedefs for Data Operations --- @@ -128,12 +129,19 @@ class DataOperationRegistry { sort: s, pagination: p, ), - 'country': (c, uid, f, s, p) => c.read>().readAll( - userId: uid, - filter: f, - sort: s, - pagination: p, - ), + 'country': (c, uid, f, s, p) async { + // For 'country' model, delegate to CountryService for specialized filtering. + // The CountryService handles the 'usage' filter and returns a List. + // We then wrap this list in a PaginatedResponse for consistency with + // the generic API response structure. + final countryService = c.read(); + final countries = await countryService.getCountries(f); + return PaginatedResponse( + items: countries, + cursor: null, // No cursor for this type of filtered list + hasMore: false, // No more items as it's a complete filtered set + ); + }, 'language': (c, uid, f, s, p) => c .read>() .readAll(userId: uid, filter: f, sort: s, pagination: p), diff --git a/lib/src/services/country_service.dart b/lib/src/services/country_service.dart new file mode 100644 index 0000000..6488a07 --- /dev/null +++ b/lib/src/services/country_service.dart @@ -0,0 +1,195 @@ +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:logging/logging.dart'; + +/// {@template country_service} +/// A service responsible for retrieving country data, including specialized +/// lists like countries associated with headlines or sources. +/// +/// This service leverages database aggregation for efficient data retrieval +/// and includes basic in-memory caching to optimize performance for frequently +/// requested lists. +/// {@endtemplate} +class CountryService { + /// {@macro country_service} + CountryService({ + required DataRepository countryRepository, + required DataRepository headlineRepository, + required DataRepository sourceRepository, + Logger? logger, + }) : _countryRepository = countryRepository, + _headlineRepository = headlineRepository, + _sourceRepository = sourceRepository, + _log = logger ?? Logger('CountryService'); + + final DataRepository _countryRepository; + final DataRepository _headlineRepository; + final DataRepository _sourceRepository; + final Logger _log; + + // In-memory caches for frequently accessed lists. + // These should be cleared periodically in a real-world application + // or invalidated upon data changes. For this scope, simple caching is used. + List? _cachedEventCountries; + List? _cachedHeadquarterCountries; + + /// Retrieves a list of countries based on the provided filter. + /// + /// Supports filtering by 'usage' to get countries that are either + /// 'eventCountry' in headlines or 'headquarters' in sources. + /// If no specific usage filter is provided, it returns all active countries. + /// + /// - [filter]: An optional map containing query parameters. + /// Expected keys: + /// - `'usage'`: String, can be 'eventCountry' or 'headquarters'. + /// + /// Throws [BadRequestException] if an unsupported usage filter is provided. + /// Throws [OperationFailedException] for internal errors during data fetch. + Future> getCountries(Map? filter) async { + _log.info('Fetching countries with filter: $filter'); + + final usage = filter?['usage'] as String?; + + if (usage == null || usage.isEmpty) { + _log.fine('No usage filter provided. Fetching all active countries.'); + return _getAllCountries(); + } + + switch (usage) { + case 'eventCountry': + _log.fine('Fetching countries used as event countries in headlines.'); + return _getEventCountries(); + case 'headquarters': + _log.fine('Fetching countries used as headquarters in sources.'); + return _getHeadquarterCountries(); + default: + _log.warning('Unsupported country usage filter: "$usage"'); + throw BadRequestException( + 'Unsupported country usage filter: "$usage". ' + 'Supported values are "eventCountry" and "headquarters".', + ); + } + } + + /// Fetches all active countries from the repository. + Future> _getAllCountries() async { + _log.finer('Retrieving all active countries from repository.'); + try { + final response = await _countryRepository.readAll( + filter: {'status': ContentStatus.active.name}, + ); + return response.items; + } catch (e, s) { + _log.severe('Failed to fetch all countries.', e, s); + throw OperationFailedException('Failed to retrieve all countries: $e'); + } + } + + /// Fetches a distinct list of countries that are referenced as + /// `eventCountry` in headlines. + /// + /// Uses MongoDB aggregation to efficiently get distinct country IDs + /// and then fetches the full Country objects. Results are cached. + Future> _getEventCountries() async { + if (_cachedEventCountries != null) { + _log.finer('Returning cached event countries.'); + return _cachedEventCountries!; + } + + _log.finer('Fetching distinct event countries via aggregation.'); + try { + final pipeline = [ + { + r'$match': { + 'status': ContentStatus.active.name, + 'eventCountry.id': {r'$exists': true}, + }, + }, + { + r'$group': { + '_id': r'$eventCountry.id', + 'country': {r'$first': r'$eventCountry'}, + }, + }, + { + r'$replaceRoot': {'newRoot': r'$country'}, + }, + ]; + + final distinctCountriesJson = await _headlineRepository.aggregate( + pipeline: pipeline, + ); + + final distinctCountries = distinctCountriesJson + .map(Country.fromJson) + .toList(); + + _cachedEventCountries = distinctCountries; + _log.info( + 'Successfully fetched and cached ${distinctCountries.length} ' + 'event countries.', + ); + return distinctCountries; + } catch (e, s) { + _log.severe('Failed to fetch event countries via aggregation.', e, s); + throw OperationFailedException('Failed to retrieve event countries: $e'); + } + } + + /// Fetches a distinct list of countries that are referenced as + /// `headquarters` in sources. + /// + /// Uses MongoDB aggregation to efficiently get distinct country IDs + /// and then fetches the full Country objects. Results are cached. + Future> _getHeadquarterCountries() async { + if (_cachedHeadquarterCountries != null) { + _log.finer('Returning cached headquarter countries.'); + return _cachedHeadquarterCountries!; + } + + _log.finer('Fetching distinct headquarter countries via aggregation.'); + try { + final pipeline = [ + { + r'$match': { + 'status': ContentStatus.active.name, + 'headquarters.id': {r'$exists': true}, + }, + }, + { + r'$group': { + '_id': r'$headquarters.id', + 'country': {r'$first': r'$headquarters'}, + }, + }, + { + r'$replaceRoot': {'newRoot': r'$country'}, + }, + ]; + + final distinctCountriesJson = await _sourceRepository.aggregate( + pipeline: pipeline, + ); + + final distinctCountries = distinctCountriesJson + .map(Country.fromJson) + .toList(); + + _cachedHeadquarterCountries = distinctCountries; + _log.info( + 'Successfully fetched and cached ${distinctCountries.length} ' + 'headquarter countries.', + ); + return distinctCountries; + } catch (e, s) { + _log.severe( + 'Failed to fetch headquarter countries via aggregation.', + e, + s, + ); + throw OperationFailedException( + 'Failed to retrieve headquarter countries: $e', + ); + } + } +} diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 38b0e44..7dcfaba 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -10,6 +10,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/registry/data_o import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/auth_token_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/country_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/token_blacklist_service.dart'; @@ -151,6 +152,7 @@ Handler middleware(Handler handler) { ), ) .use(provider((_) => deps.rateLimitService)) + .use(provider((_) => deps.countryService)) .call(context); }; });