diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index ca747e5..6266b47 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -9,7 +9,6 @@ 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'; @@ -62,7 +61,6 @@ class AppDependencies { late final EmailRepository emailRepository; // Services - late final CountryService countryService; late final TokenBlacklistService tokenBlacklistService; late final AuthTokenService authTokenService; late final VerificationCodeStorageService verificationCodeStorageService; @@ -239,12 +237,6 @@ 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 f649179..caf2371 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -2,7 +2,6 @@ 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 --- @@ -129,32 +128,12 @@ class DataOperationRegistry { sort: s, pagination: p, ), - 'country': (c, uid, f, s, p) async { - final usage = f?['usage'] as String?; - final name = f?['name'] as String?; - - // If either 'usage' or 'name' filter is present, delegate to CountryService. - // Sorting and pagination are handled by CountryService for these specialized queries. - if ((usage != null && usage.isNotEmpty) || - (name != null && name.isNotEmpty)) { - 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 - ); - } else { - // For standard requests without specialized filters, use the repository - // which supports pagination/sorting. - return c.read>().readAll( - userId: uid, - filter: f, - sort: s, - pagination: p, - ); - } - }, + 'country': (c, uid, f, s, p) => c.read>().readAll( + userId: uid, + filter: f, + sort: s, + pagination: p, + ), '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 deleted file mode 100644 index 8ac0527..0000000 --- a/lib/src/services/country_service.dart +++ /dev/null @@ -1,337 +0,0 @@ -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:logging/logging.dart'; - -/// {@template _cache_entry} -/// A simple class to hold cached data along with its expiration time. -/// {@endtemplate} -class _CacheEntry { - /// {@macro _cache_entry} - const _CacheEntry(this.data, this.expiry); - - /// The cached data. - final T data; - - /// The time at which the cached data expires. - final DateTime expiry; - - /// Checks if the cache entry is still valid (not expired). - bool isValid() => DateTime.now().isBefore(expiry); -} - -/// {@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 time-based 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; - - // Cache duration for aggregated country lists (e.g., 1 hour). - static const Duration _cacheDuration = Duration(hours: 1); - - // In-memory caches for frequently accessed lists with time-based invalidation. - final Map>> _cachedEventCountries = {}; - final Map>> _cachedHeadquarterCountries = - {}; - - // Futures to hold in-flight aggregation requests to prevent cache stampedes. - final Map>> _eventCountriesFutures = {}; - final Map>> _headquarterCountriesFutures = {}; - - /// 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. - /// It also supports filtering by 'name' (full or partial match). - /// - /// - [filter]: An optional map containing query parameters. - /// Expected keys: - /// - `'usage'`: String, can be 'eventCountry' or 'headquarters'. - /// - `'name'`: String, a full or partial country name for search. - /// - /// 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?; - final q = filter?['q'] as String?; - - Map? nameFilter; - if (q != null && q.isNotEmpty) { - // Create a case-insensitive regex filter for the name. - nameFilter = {r'$regex': q, r'$options': 'i'}; - } - - if (usage == null || usage.isEmpty) { - _log.fine( - 'No usage filter provided. Fetching all active countries ' - 'with nameFilter: $nameFilter.', - ); - return _getAllCountries(nameFilter: nameFilter); - } - - switch (usage) { - case 'eventCountry': - _log.fine( - 'Fetching countries used as event countries in headlines ' - 'with nameFilter: $nameFilter.', - ); - return _getEventCountries(nameFilter: nameFilter); - case 'headquarters': - _log.fine( - 'Fetching countries used as headquarters in sources ' - 'with nameFilter: $nameFilter.', - ); - return _getHeadquarterCountries(nameFilter: nameFilter); - 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. - /// - /// - [nameFilter]: An optional map containing a regex filter for the country name. - Future> _getAllCountries({ - Map? nameFilter, - }) async { - _log.finer( - 'Retrieving all active countries from repository with nameFilter: $nameFilter.', - ); - try { - final combinedFilter = { - 'status': ContentStatus.active.name, - }; - if (nameFilter != null && nameFilter.isNotEmpty) { - combinedFilter.addAll({'name': nameFilter}); - } - - final response = await _countryRepository.readAll(filter: combinedFilter); - return response.items; - } catch (e, s) { - _log.severe( - 'Failed to fetch all countries with nameFilter: $nameFilter.', - 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. - /// - /// - [nameFilter]: An optional map containing a regex filter for the country name. - Future> _getEventCountries({ - Map? nameFilter, - }) async { - final cacheKey = 'eventCountry_${nameFilter ?? 'noFilter'}'; - if (_cachedEventCountries.containsKey(cacheKey) && - _cachedEventCountries[cacheKey]!.isValid()) { - _log.finer('Returning cached event countries for key: $cacheKey.'); - return _cachedEventCountries[cacheKey]!.data; - } - // Atomically retrieve or create the future for the specific cache key. - var future = _eventCountriesFutures[cacheKey]; - if (future == null) { - future = _fetchAndCacheEventCountries( - nameFilter: nameFilter, - ).whenComplete(() => _eventCountriesFutures.remove(cacheKey)); - _eventCountriesFutures[cacheKey] = future; - } - return future; - } - - /// 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. - /// - /// - [nameFilter]: An optional map containing a regex filter for the country name. - Future> _getHeadquarterCountries({ - Map? nameFilter, - }) async { - final cacheKey = 'headquarters_${nameFilter ?? 'noFilter'}'; - if (_cachedHeadquarterCountries.containsKey(cacheKey) && - _cachedHeadquarterCountries[cacheKey]!.isValid()) { - _log.finer('Returning cached headquarter countries for key: $cacheKey.'); - return _cachedHeadquarterCountries[cacheKey]!.data; - } - // Atomically retrieve or create the future for the specific cache key. - var future = _headquarterCountriesFutures[cacheKey]; - if (future == null) { - future = _fetchAndCacheHeadquarterCountries( - nameFilter: nameFilter, - ).whenComplete(() => _headquarterCountriesFutures.remove(cacheKey)); - _headquarterCountriesFutures[cacheKey] = future; - } - return future; - } - - /// Helper method to fetch and cache distinct event countries. - /// - /// - [nameFilter]: An optional map containing a regex filter for the country name. - Future> _fetchAndCacheEventCountries({ - Map? nameFilter, - }) async { - _log.finer( - 'Fetching distinct event countries via aggregation with nameFilter: $nameFilter.', - ); - try { - final distinctCountries = await _getDistinctCountriesFromAggregation( - repository: _headlineRepository, - fieldName: 'eventCountry', - nameFilter: nameFilter, - ); - final cacheKey = 'eventCountry_${nameFilter ?? 'noFilter'}'; - _cachedEventCountries[cacheKey] = _CacheEntry( - distinctCountries, - DateTime.now().add(_cacheDuration), - ); - _log.info( - 'Successfully fetched and cached ${distinctCountries.length} ' - 'event countries for key: $cacheKey.', - ); - return distinctCountries; - } catch (e, s) { - _log.severe( - 'Failed to fetch distinct event countries via aggregation ' - 'with nameFilter: $nameFilter.', - e, - s, - ); - rethrow; // Re-throw the original exception - } - } - - /// Helper method to fetch and cache distinct headquarter countries. - /// - /// - [nameFilter]: An optional map containing a regex filter for the country name. - Future> _fetchAndCacheHeadquarterCountries({ - Map? nameFilter, - }) async { - _log.finer( - 'Fetching distinct headquarter countries via aggregation with nameFilter: $nameFilter.', - ); - try { - final distinctCountries = await _getDistinctCountriesFromAggregation( - repository: _sourceRepository, - fieldName: 'headquarters', - nameFilter: nameFilter, - ); - final cacheKey = 'headquarters_${nameFilter ?? 'noFilter'}'; - _cachedHeadquarterCountries[cacheKey] = _CacheEntry( - distinctCountries, - DateTime.now().add(_cacheDuration), - ); - _log.info( - 'Successfully fetched and cached ${distinctCountries.length} ' - 'headquarter countries for key: $cacheKey.', - ); - return distinctCountries; - } catch (e, s) { - _log.severe( - 'Failed to fetch distinct headquarter countries via aggregation ' - 'with nameFilter: $nameFilter.', - e, - s, - ); - rethrow; // Re-throw the original exception - } - } - - /// Helper method to fetch a distinct list of countries from a given - /// repository and field name using MongoDB aggregation. - /// - /// - [repository]: The [DataRepository] to perform the aggregation on. - /// - [fieldName]: The name of the field within the documents that contains - /// the country object (e.g., 'eventCountry', 'headquarters'). - /// - [nameFilter]: An optional map containing a regex filter for the country name. - /// - /// Throws [OperationFailedException] for internal errors during data fetch. - Future> - _getDistinctCountriesFromAggregation({ - required DataRepository repository, - required String fieldName, - Map? nameFilter, - }) async { - _log.finer( - 'Fetching distinct countries for field "$fieldName" via aggregation ' - 'with nameFilter: $nameFilter.', - ); - try { - final matchStage = { - 'status': ContentStatus.active.name, - '$fieldName.id': {r'$exists': true}, - }; - - // Add name filter if provided - if (nameFilter != null && nameFilter.isNotEmpty) { - matchStage['$fieldName.name'] = nameFilter; - } - - final pipeline = >[ - {r'$match': matchStage}, - { - r'$group': { - '_id': '\$$fieldName.id', - 'country': {r'$first': '\$$fieldName'}, - }, - }, - { - r'$replaceRoot': {'newRoot': r'$country'}, - }, - ]; - - final distinctCountriesJson = await repository.aggregate( - pipeline: pipeline, - ); - - final distinctCountries = distinctCountriesJson - .map(Country.fromJson) - .toList(); - - _log.info( - 'Successfully fetched ${distinctCountries.length} distinct countries ' - 'for field "$fieldName" with nameFilter: $nameFilter.', - ); - return distinctCountries; - } catch (e, s) { - _log.severe( - 'Failed to fetch distinct countries for field "$fieldName" ' - 'with nameFilter: $nameFilter.', - e, - s, - ); - throw OperationFailedException( - 'Failed to retrieve distinct countries for field "$fieldName": $e', - ); - } - } -} diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 489d179..1c4636b 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -131,33 +131,6 @@ class DatabaseSeedingService { .collection('countries') .createIndex(keys: {'name': 1}, name: 'countries_name_index'); - /// Indexes for country aggregation queries. - /// This compound index optimizes queries that filter by 'status' and - /// group by 'eventCountry.id' in the headlines collection. - await _db - .collection('headlines') - .createIndex( - keys: {'status': 1, 'eventCountry.id': 1}, - name: 'status_eventCountry_index', - ); - /// Index for efficient filtering of headlines by the name of the - /// associated event country. This is crucial for the country search - /// functionality when filtering by 'eventCountry' usage. - await _db - .collection('headlines') - .createIndex( - keys: {'eventCountry.name': 1}, - name: 'eventCountry_name_index', - ); - /// This compound index optimizes queries that filter by 'status' and - /// group by 'headquarters.id' in the sources collection. - await _db - .collection('sources') - .createIndex( - keys: {'status': 1, 'headquarters.id': 1}, - name: 'status_headquarters_index', - ); - // --- TTL and Unique Indexes via runCommand --- // The following indexes are created using the generic `runCommand` because // they require specific options not exposed by the simpler `createIndex` diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 7dcfaba..38b0e44 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -10,7 +10,6 @@ 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'; @@ -152,7 +151,6 @@ Handler middleware(Handler handler) { ), ) .use(provider((_) => deps.rateLimitService)) - .use(provider((_) => deps.countryService)) .call(context); }; });