From 4c638c0c6eba86532ab1073165925ac4388531f3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 20 Aug 2025 06:59:52 +0100 Subject: [PATCH 1/6] feat(service): implement CountryService for efficient country data retrieval - Add CountryService class to handle country data operations - Implement methods to fetch countries based on different usage filters - Use in-memory caching for frequently accessed lists - Leverage database aggregation for efficient data retrieval - Include error handling for unsupported filters and data fetch failures --- lib/src/services/country_service.dart | 185 ++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 lib/src/services/country_service.dart diff --git a/lib/src/services/country_service.dart b/lib/src/services/country_service.dart new file mode 100644 index 0000000..13e16c7 --- /dev/null +++ b/lib/src/services/country_service.dart @@ -0,0 +1,185 @@ +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.toString()}', + ); + } + } + + /// 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((json) => Country.fromJson(json)) + .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.toString()}', + ); + } + } + + /// 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((json) => Country.fromJson(json)) + .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.toString()}', + ); + } + } +} From 9a32c059120998692ba9935c210af7c2bb86c859 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 20 Aug 2025 07:03:11 +0100 Subject: [PATCH 2/6] feat(services): add country service to app dependencies - Import CountryService from 'country_service.dart' - Initialize CountryService in AppDependencies class - Set up CountryService with required repositories and logger --- lib/src/config/app_dependencies.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 73827ce..17a18d0 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.'); From d4a13dc97618ead1ac5f77535064fd4c8deee588 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 20 Aug 2025 07:03:26 +0100 Subject: [PATCH 3/6] feat(routes): add country service to middleware - Import CountryService from services package - Add CountryService to the middleware using provider --- routes/_middleware.dart | 2 ++ 1 file changed, 2 insertions(+) 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); }; }); From 3f56f3983b874742a7a933803d91f799d6bbbe4a Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 20 Aug 2025 07:05:06 +0100 Subject: [PATCH 4/6] refactor(country): implement specialized filtering for country model - Replace generic DataRepository readAll method with CountryService for country model - Add support for 'usage' filter in country data operation - Return PaginatedResponse to maintain consistency with generic API structure - Remove unnecessary parameters (uid, s, p) from the custom operation --- lib/src/registry/data_operation_registry.dart | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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), From f160e7055c57ea061a4e32fe624298ec1d940310 Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 20 Aug 2025 07:11:16 +0100 Subject: [PATCH 5/6] lint: misc --- lib/src/services/country_service.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/src/services/country_service.dart b/lib/src/services/country_service.dart index 13e16c7..e66fb88 100644 --- a/lib/src/services/country_service.dart +++ b/lib/src/services/country_service.dart @@ -82,7 +82,7 @@ class CountryService { } catch (e, s) { _log.severe('Failed to fetch all countries.', e, s); throw OperationFailedException( - 'Failed to retrieve all countries: ${e.toString()}', + 'Failed to retrieve all countries: $e', ); } } @@ -120,7 +120,7 @@ class CountryService { await _headlineRepository.aggregate(pipeline: pipeline); final distinctCountries = distinctCountriesJson - .map((json) => Country.fromJson(json)) + .map(Country.fromJson) .toList(); _cachedEventCountries = distinctCountries; @@ -130,7 +130,7 @@ class CountryService { } catch (e, s) { _log.severe('Failed to fetch event countries via aggregation.', e, s); throw OperationFailedException( - 'Failed to retrieve event countries: ${e.toString()}', + 'Failed to retrieve event countries: $e', ); } } @@ -168,7 +168,7 @@ class CountryService { await _sourceRepository.aggregate(pipeline: pipeline); final distinctCountries = distinctCountriesJson - .map((json) => Country.fromJson(json)) + .map(Country.fromJson) .toList(); _cachedHeadquarterCountries = distinctCountries; @@ -178,7 +178,7 @@ class CountryService { } catch (e, s) { _log.severe('Failed to fetch headquarter countries via aggregation.', e, s); throw OperationFailedException( - 'Failed to retrieve headquarter countries: ${e.toString()}', + 'Failed to retrieve headquarter countries: $e', ); } } From 418d3e6c7b544b7ed523d4f9e603ff7b4637251b Mon Sep 17 00:00:00 2001 From: fulleni Date: Wed, 20 Aug 2025 07:11:38 +0100 Subject: [PATCH 6/6] style: format misc --- lib/src/config/app_dependencies.dart | 2 +- lib/src/services/country_service.dart | 52 ++++++++++++++++----------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 17a18d0..ca747e5 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -239,7 +239,7 @@ class AppDependencies { connectionManager: _mongoDbConnectionManager, log: Logger('MongoDbRateLimitService'), ); - countryService = CountryService( + countryService = CountryService( countryRepository: countryRepository, headlineRepository: headlineRepository, sourceRepository: sourceRepository, diff --git a/lib/src/services/country_service.dart b/lib/src/services/country_service.dart index e66fb88..6488a07 100644 --- a/lib/src/services/country_service.dart +++ b/lib/src/services/country_service.dart @@ -17,10 +17,10 @@ class CountryService { required DataRepository headlineRepository, required DataRepository sourceRepository, Logger? logger, - }) : _countryRepository = countryRepository, - _headlineRepository = headlineRepository, - _sourceRepository = sourceRepository, - _log = logger ?? Logger('CountryService'); + }) : _countryRepository = countryRepository, + _headlineRepository = headlineRepository, + _sourceRepository = sourceRepository, + _log = logger ?? Logger('CountryService'); final DataRepository _countryRepository; final DataRepository _headlineRepository; @@ -81,9 +81,7 @@ class CountryService { return response.items; } catch (e, s) { _log.severe('Failed to fetch all countries.', e, s); - throw OperationFailedException( - 'Failed to retrieve all countries: $e', - ); + throw OperationFailedException('Failed to retrieve all countries: $e'); } } @@ -113,25 +111,28 @@ class CountryService { 'country': {r'$first': r'$eventCountry'}, }, }, - {r'$replaceRoot': {'newRoot': r'$country'}}, + { + r'$replaceRoot': {'newRoot': r'$country'}, + }, ]; - final distinctCountriesJson = - await _headlineRepository.aggregate(pipeline: pipeline); + 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.'); + _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', - ); + throw OperationFailedException('Failed to retrieve event countries: $e'); } } @@ -161,22 +162,31 @@ class CountryService { 'country': {r'$first': r'$headquarters'}, }, }, - {r'$replaceRoot': {'newRoot': r'$country'}}, + { + r'$replaceRoot': {'newRoot': r'$country'}, + }, ]; - final distinctCountriesJson = - await _sourceRepository.aggregate(pipeline: pipeline); + 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.'); + _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); + _log.severe( + 'Failed to fetch headquarter countries via aggregation.', + e, + s, + ); throw OperationFailedException( 'Failed to retrieve headquarter countries: $e', );