diff --git a/.env.example b/.env.example
index 54f133d4..f7a80581 100644
--- a/.env.example
+++ b/.env.example
@@ -60,9 +60,28 @@ FIREBASE_PRIVATE_KEY="your-firebase-private-key"
ONESIGNAL_APP_ID="your-onesignal-app-id"
ONESIGNAL_REST_API_KEY="your-onesignal-rest-api-key"
+# -----------------------------------------------------------------------------
+# SECTION 4: ANALYTICS PROVIDERS (CONDITIONALLY REQUIRED)
+# Provide credentials for analytics providers to enable dashboard metrics.
+# The server will start without these, but the analytics sync worker will skip them.
+# -----------------------------------------------------------------------------
+
+# --- Google Analytics (via Firebase) ---
+# The ID of your Google Analytics 4 property.
+# Note: This requires the Firebase credentials from Section 3 to be configured
+# as it uses the same authentication mechanism.
+GOOGLE_ANALYTICS_PROPERTY_ID="your-ga4-property-id"
+
+# --- Mixpanel ---
+# The Project ID for your Mixpanel project.
+MIXPANEL_PROJECT_ID="your-mixpanel-project-id"
+# The username for your Mixpanel service account.
+MIXPANEL_SERVICE_ACCOUNT_USERNAME="your-mixpanel-service-account-username"
+# The secret for your Mixpanel service account.
+MIXPANEL_SERVICE_ACCOUNT_SECRET="your-mixpanel-service-account-secret"
# -----------------------------------------------------------------------------
-# SECTION 4: API SECURITY & RATE LIMITING (OPTIONAL)
+# SECTION 5: API SECURITY & RATE LIMITING (OPTIONAL)
# Fine-tune security settings. Defaults are provided if these are not set.
# -----------------------------------------------------------------------------
@@ -80,7 +99,7 @@ ONESIGNAL_REST_API_KEY="your-onesignal-rest-api-key"
# -----------------------------------------------------------------------------
-# SECTION 5: ADVANCED & MISCELLANEOUS CONFIGURATION (OPTIONAL)
+# SECTION 6: ADVANCED & MISCELLANEOUS CONFIGURATION (OPTIONAL)
# -----------------------------------------------------------------------------
# The duration for which a JWT is valid, in hours. Defaults to 720 (30 days).
diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
index 1246f5b2..fe0704b4 100644
--- a/.github/workflows/main.yaml
+++ b/.github/workflows/main.yaml
@@ -16,4 +16,4 @@ jobs:
build:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1
with:
- min_coverage: 0
\ No newline at end of file
+ min_coverage: 40
\ No newline at end of file
diff --git a/README.md b/README.md
index 8477a251..81a0e99a 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
@@ -107,11 +107,43 @@ A complete, multi-provider notification engine empowers you to engage users with
+
+📊 Insightful Analytics Engine
+
+### 📈 A Unified Business Intelligence Engine
+A complete, multi-provider analytics engine that transforms raw data from both external services and your own application database into insightful, aggregated metrics for your dashboard.
+- **Dual-Source ETL:** A standalone worker process runs on a schedule to perform a full Extract, Transform, and Load (ETL) operation. It pulls behavioral data from your chosen analytics provider (Google Analytics or Mixpanel) and combines it with operational data by running direct, complex aggregations against the application's own database.
+- **High-Performance Dashboard:** The web dashboard reads this pre-aggregated data, resulting in near-instant load times for all analytics charts and metrics. This architecture avoids slow, direct, on-the-fly queries from the client to the analytics provider.
+- **Provider-Agnostic Design:** The engine is built on a provider-agnostic interface. You can switch between Google Analytics and Mixpanel via a simple configuration change, without altering any code.
+- **Extensible & Scalable:** Adding new charts or KPIs is as simple as defining a new mapping. The system is designed to be easily extended to track new metrics as your application evolves.
+> **Your Advantage:** Get a complete, production-grade BI pipeline out of the box. Deliver a fast, responsive dashboard and gain a holistic view of your business by combining user behavior analytics with real-time operational metrics—a capability that external analytics tools alone cannot provide.
+
+
+
🏗️ Architecture & Infrastructure
### 🚀 High-Performance by Design
-Built on a modern, minimalist foundation to ensure low latency and excellent performance.
+- **Dart Frog Core:** Leverages the high-performance Dart Frog framework for a fast, efficient, and scalable backend.
+- **Clean, Layered Architecture:** A strict separation of concerns into distinct layers makes the codebase clean, maintainable, and easy to reason about.
+> **Your Advantage:** Your backend is built on a solid, modern foundation that is both powerful and a pleasure to work with, reducing maintenance overhead.
+
+---
+
+### 🔌 Extensible & Unlocked
+The entire application is designed with a robust dependency injection system, giving you the freedom to choose your own infrastructure.
+- **Swappable Implementations:** Easily swap out core components—like the database, email provider, or file storage service—without rewriting business logic.
+> **Your Advantage:** Avoid vendor lock-in and future-proof your application. You have the freedom to adapt and evolve your tech stack as your business needs change.
+
+---
+
+### 🔄 Automated & Traceable Database Migrations
+Say goodbye to risky manual database updates. A professional, versioned migration system ensures your database schema evolves safely and automatically.
+- **Code-Driven Schema Evolution:** The system automatically applies schema changes to your database on application startup, ensuring consistency across all environments.
+- **Traceable to Source:** Each migration is versioned and directly linked to the pull request that initiated it, providing a clear, auditable history of every change.
+> **Your Advantage:** Deploy with confidence. This robust system eliminates an entire class of deployment errors, ensuring your data models evolve gracefully and reliably with full traceability.
+
+
## 🔑 Licensing
This `Flutter News App API Server` package is an integral part of the [**Flutter News App Full Source Code Toolkit**](https://github.com/flutter-news-app-full-source-code). For comprehensive details regarding licensing, including trial and commercial options for the entire toolkit, please refer to the main toolkit organization page.
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 7b5c1a64..7319e7d3 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -12,6 +12,8 @@ analyzer:
one_member_abstracts: ignore
cascade_invocations: ignore
cast_nullable_to_non_nullable: ignore
+ specify_nonobvious_property_types: ignore
+ unnecessary_null_checks: ignore
exclude:
- build/**
linter:
diff --git a/bin/analytics_sync_worker.dart b/bin/analytics_sync_worker.dart
new file mode 100644
index 00000000..01985c6e
--- /dev/null
+++ b/bin/analytics_sync_worker.dart
@@ -0,0 +1,35 @@
+import 'dart:io';
+
+import 'package:flutter_news_app_api_server_full_source_code/src/config/app_dependencies.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/services/analytics/analytics.dart';
+import 'package:logging/logging.dart';
+
+/// The main entry point for the standalone Analytics Sync Worker process.
+///
+/// This script initializes application dependencies, retrieves the
+/// [AnalyticsSyncService], and executes its `run()` method to perform the
+/// periodic data synchronization.
+///
+/// This executable can be compiled into a native binary and run by a scheduler
+/// (e.g., a cron job) to automate the analytics data pipeline.
+Future main(List args) async {
+ // Configure logger for console output.
+ Logger.root.level = Level.ALL;
+ Logger.root.onRecord.listen((record) {
+ // ignore: avoid_print
+ print('${record.level.name}: ${record.time}: ${record.message}');
+ if (record.error != null) {
+ // ignore: avoid_print
+ print(' ERROR: ${record.error}');
+ }
+ if (record.stackTrace != null) {
+ // ignore: avoid_print
+ print(' STACK TRACE: ${record.stackTrace}');
+ }
+ });
+
+ await AppDependencies.instance.init();
+ await AppDependencies.instance.analyticsSyncService.run();
+ await AppDependencies.instance.dispose();
+ exit(0);
+}
diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart
index a82b820f..8b772d4a 100644
--- a/lib/src/config/app_dependencies.dart
+++ b/lib/src/config/app_dependencies.dart
@@ -10,10 +10,10 @@ import 'package:email_sendgrid/email_sendgrid.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/config/environment_config.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/all_migrations.dart';
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/analytics/analytics.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_query_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_migration_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_action_limit_service.dart';
@@ -72,6 +72,9 @@ class AppDependencies {
pushNotificationDeviceRepository;
late final DataRepository remoteConfigRepository;
late final DataRepository inAppNotificationRepository;
+ late final DataRepository kpiCardDataRepository;
+ late final DataRepository chartCardDataRepository;
+ late final DataRepository rankedListCardDataRepository;
late final DataRepository engagementRepository;
late final DataRepository reportRepository;
@@ -79,12 +82,12 @@ class AppDependencies {
late final EmailRepository emailRepository;
// Services
+ late final AnalyticsSyncService analyticsSyncService;
late final DatabaseMigrationService databaseMigrationService;
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 UserActionLimitService userActionLimitService;
late final RateLimitService rateLimitService;
@@ -232,6 +235,24 @@ class AppDependencies {
logger: Logger('DataMongodb'),
);
+ final kpiCardDataClient = DataMongodb(
+ connectionManager: _mongoDbConnectionManager,
+ modelName: 'kpi_card_data',
+ fromJson: KpiCardData.fromJson,
+ toJson: (item) => item.toJson(),
+ );
+ final chartCardDataClient = DataMongodb(
+ connectionManager: _mongoDbConnectionManager,
+ modelName: 'chart_card_data',
+ fromJson: ChartCardData.fromJson,
+ toJson: (item) => item.toJson(),
+ );
+ final rankedListCardDataClient = DataMongodb(
+ connectionManager: _mongoDbConnectionManager,
+ modelName: 'ranked_list_card_data',
+ fromJson: RankedListCardData.fromJson,
+ toJson: (item) => item.toJson(),
+ );
_log.info('Initialized data client for InAppNotification.');
final engagementClient = DataMongodb(
@@ -349,6 +370,13 @@ class AppDependencies {
engagementRepository = DataRepository(dataClient: engagementClient);
reportRepository = DataRepository(dataClient: reportClient);
appReviewRepository = DataRepository(dataClient: appReviewClient);
+ kpiCardDataRepository = DataRepository(dataClient: kpiCardDataClient);
+ chartCardDataRepository = DataRepository(
+ dataClient: chartCardDataClient,
+ );
+ rankedListCardDataRepository = DataRepository(
+ dataClient: rankedListCardDataClient,
+ );
// Configure the HTTP client for SendGrid.
// The HttpClient's AuthInterceptor will use the tokenProvider to add
@@ -394,11 +422,6 @@ class AppDependencies {
userContentPreferencesRepository: userContentPreferencesRepository,
log: Logger('AuthService'),
);
- dashboardSummaryService = DashboardSummaryService(
- headlineRepository: headlineRepository,
- topicRepository: topicRepository,
- sourceRepository: sourceRepository,
- );
userActionLimitService = DefaultUserActionLimitService(
remoteConfigRepository: remoteConfigRepository,
engagementRepository: engagementRepository,
@@ -424,6 +447,69 @@ class AppDependencies {
log: Logger('DefaultPushNotificationService'),
);
+ // --- Analytics Services ---
+ final gaPropertyId = EnvironmentConfig.googleAnalyticsPropertyId;
+ final mpProjectId = EnvironmentConfig.mixpanelProjectId;
+ final mpUser = EnvironmentConfig.mixpanelServiceAccountUsername;
+ final mpSecret = EnvironmentConfig.mixpanelServiceAccountSecret;
+
+ GoogleAnalyticsDataClient? googleAnalyticsClient;
+ if (gaPropertyId != null && firebaseAuthenticator != null) {
+ final googleAnalyticsHttpClient = HttpClient(
+ baseUrl: 'https://analyticsdata.googleapis.com/v1beta',
+ tokenProvider: firebaseAuthenticator!.getAccessToken,
+ logger: Logger('GoogleAnalyticsHttpClient'),
+ );
+
+ googleAnalyticsClient = GoogleAnalyticsDataClient(
+ headlineRepository: headlineRepository,
+ propertyId: gaPropertyId,
+ firebaseAuthenticator: firebaseAuthenticator!,
+ log: Logger('GoogleAnalyticsDataClient'),
+ httpClient: googleAnalyticsHttpClient,
+ );
+ } else {
+ _log.warning(
+ 'Google Analytics client could not be initialized due to missing '
+ 'property ID or Firebase authenticator.',
+ );
+ }
+
+ MixpanelDataClient? mixpanelClient;
+ if (mpProjectId != null && mpUser != null && mpSecret != null) {
+ mixpanelClient = MixpanelDataClient(
+ headlineRepository: headlineRepository,
+ projectId: mpProjectId,
+ serviceAccountUsername: mpUser,
+ serviceAccountSecret: mpSecret,
+ log: Logger('MixpanelDataClient'),
+ );
+ } else {
+ _log.warning(
+ 'Mixpanel client could not be initialized due to missing credentials.',
+ );
+ }
+
+ final analyticsMetricMapper = AnalyticsMetricMapper();
+
+ analyticsSyncService = AnalyticsSyncService(
+ remoteConfigRepository: remoteConfigRepository,
+ kpiCardRepository: kpiCardDataRepository,
+ chartCardRepository: chartCardDataRepository,
+ rankedListCardRepository: rankedListCardDataRepository,
+ userRepository: userRepository,
+ topicRepository: topicRepository,
+ sourceRepository: sourceRepository,
+ reportRepository: reportRepository,
+ headlineRepository: headlineRepository,
+ googleAnalyticsClient: googleAnalyticsClient,
+ mixpanelClient: mixpanelClient,
+ analyticsMetricMapper: analyticsMetricMapper,
+ engagementRepository: engagementRepository,
+ appReviewRepository: appReviewRepository,
+ log: Logger('AnalyticsSyncService'),
+ );
+
_log.info('Application dependencies initialized successfully.');
// Signal that initialization has completed successfully.
_initCompleter!.complete();
@@ -459,7 +545,7 @@ class AppDependencies {
await _mongoDbConnectionManager.close();
tokenBlacklistService.dispose();
rateLimitService.dispose();
- countryQueryService.dispose(); // Dispose the new service
+ countryQueryService.dispose();
// Reset the completer to allow for re-initialization (e.g., in tests).
_initCompleter = null;
diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart
index ca569687..e3e04c44 100644
--- a/lib/src/config/environment_config.dart
+++ b/lib/src/config/environment_config.dart
@@ -219,4 +219,19 @@ abstract final class EnvironmentConfig {
///
/// The value is read from the `ONESIGNAL_REST_API_KEY` environment variable, if available.
static String? get oneSignalRestApiKey => _getEnv('ONESIGNAL_REST_API_KEY');
+
+ /// Retrieves the Google Analytics Property ID from the environment.
+ static String? get googleAnalyticsPropertyId =>
+ _getEnv('GOOGLE_ANALYTICS_PROPERTY_ID');
+
+ /// Retrieves the Mixpanel Project ID from the environment.
+ static String? get mixpanelProjectId => _getEnv('MIXPANEL_PROJECT_ID');
+
+ /// Retrieves the Mixpanel Service Account Username from the environment.
+ static String? get mixpanelServiceAccountUsername =>
+ _getEnv('MIXPANEL_SERVICE_ACCOUNT_USERNAME');
+
+ /// Retrieves the Mixpanel Service Account Secret from the environment.
+ static String? get mixpanelServiceAccountSecret =>
+ _getEnv('MIXPANEL_SERVICE_ACCOUNT_SECRET');
}
diff --git a/lib/src/models/analytics/analytics.dart b/lib/src/models/analytics/analytics.dart
new file mode 100644
index 00000000..3c55c86d
--- /dev/null
+++ b/lib/src/models/analytics/analytics.dart
@@ -0,0 +1,5 @@
+export 'analytics_query.dart';
+export 'google_analytics_request.dart';
+export 'google_analytics_response.dart';
+export 'mixpanel_request.dart';
+export 'mixpanel_response.dart';
diff --git a/lib/src/models/analytics/analytics_query.dart b/lib/src/models/analytics/analytics_query.dart
new file mode 100644
index 00000000..3fea38c1
--- /dev/null
+++ b/lib/src/models/analytics/analytics_query.dart
@@ -0,0 +1,60 @@
+import 'package:core/core.dart';
+
+/// A sealed class representing a structured, provider-agnostic analytics query.
+///
+/// This replaces the fragile pattern of passing primitive strings for metrics
+/// and dimensions, centralizing query definitions into type-safe objects.
+sealed class AnalyticsQuery {
+ /// {@macro analytics_query}
+ const AnalyticsQuery();
+}
+
+/// A sealed class for queries that return a numeric metric value, such as
+/// a total count or a time series.
+sealed class MetricQuery extends AnalyticsQuery {
+ /// {@macro metric_query}
+ const MetricQuery();
+}
+
+/// A query for a simple event count.
+///
+/// This is used when the metric is the count of a specific [AnalyticsEvent].
+class EventCountQuery extends MetricQuery {
+ /// {@macro event_count_query}
+ const EventCountQuery({required this.event});
+
+ /// The core, type-safe event from the shared [AnalyticsEvent] enum.
+ final AnalyticsEvent event;
+}
+
+/// A query for a standard, provider-defined metric (e.g., 'activeUsers').
+///
+/// This is used for metrics that have a built-in name in the provider's API.
+class StandardMetricQuery extends MetricQuery {
+ /// {@macro standard_metric_query}
+ const StandardMetricQuery({required this.metric});
+
+ /// The provider-specific name for a standard metric.
+ final String metric;
+}
+
+/// A query for a ranked list of items.
+///
+/// This is used to get a "Top N" list, such as most viewed headlines.
+class RankedListQuery extends AnalyticsQuery {
+ /// {@macro ranked_list_query}
+ const RankedListQuery({
+ required this.event,
+ required this.dimension,
+ this.limit = 10,
+ });
+
+ /// The event to count for ranking (e.g., `contentViewed`).
+ final AnalyticsEvent event;
+
+ /// The property/dimension to group by (e.g., `contentId`).
+ final String dimension;
+
+ /// The number of items to return in the ranked list.
+ final int limit;
+}
diff --git a/lib/src/models/analytics/google_analytics_request.dart b/lib/src/models/analytics/google_analytics_request.dart
new file mode 100644
index 00000000..8ae2242d
--- /dev/null
+++ b/lib/src/models/analytics/google_analytics_request.dart
@@ -0,0 +1,223 @@
+import 'package:equatable/equatable.dart';
+import 'package:json_annotation/json_annotation.dart';
+
+part 'google_analytics_request.g.dart';
+
+/// {@template run_report_request}
+/// Represents the request body for the Google Analytics Data API's `runReport`.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ includeIfNull: false,
+ checked: true,
+)
+class RunReportRequest extends Equatable {
+ /// {@macro run_report_request}
+ const RunReportRequest({
+ required this.dateRanges,
+ this.dimensions,
+ this.metrics,
+ this.dimensionFilter,
+ this.limit,
+ });
+
+ /// Creates a [RunReportRequest] from JSON data.
+ factory RunReportRequest.fromJson(Map json) =>
+ _$RunReportRequestFromJson(json);
+
+ /// The date ranges for which to retrieve data.
+ final List dateRanges;
+
+ /// The dimensions to include in the report.
+ final List? dimensions;
+
+ /// The metrics to include in the report.
+ final List? metrics;
+
+ /// A filter to apply to the dimensions.
+ final GARequestFilterExpression? dimensionFilter;
+
+ /// The maximum number of rows to return.
+ final int? limit;
+
+ /// Converts this [RunReportRequest] instance to JSON data.
+ Map toJson() => _$RunReportRequestToJson(this);
+
+ @override
+ @JsonKey(includeToJson: false)
+ List get props => [
+ dateRanges,
+ dimensions,
+ metrics,
+ dimensionFilter,
+ limit,
+ ];
+}
+
+/// {@template ga_request_date_range}
+/// Represents a date range for a Google Analytics report request.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ includeIfNull: false,
+ checked: true,
+)
+class GARequestDateRange extends Equatable {
+ /// {@macro ga_request_date_range}
+ const GARequestDateRange({required this.startDate, required this.endDate});
+
+ /// Creates a [GARequestDateRange] from JSON data.
+ factory GARequestDateRange.fromJson(Map json) =>
+ _$GARequestDateRangeFromJson(json);
+
+ /// The start date in 'YYYY-MM-DD' format.
+ final String startDate;
+
+ /// The end date in 'YYYY-MM-DD' format.
+ final String endDate;
+
+ /// Converts this [GARequestDateRange] instance to JSON data.
+ Map toJson() => _$GARequestDateRangeToJson(this);
+
+ @override
+ @JsonKey(includeToJson: false)
+ List get props => [startDate, endDate];
+}
+
+/// {@template ga_request_dimension}
+/// Represents a dimension to include in a Google Analytics report request.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ includeIfNull: false,
+ checked: true,
+)
+class GARequestDimension extends Equatable {
+ /// {@macro ga_request_dimension}
+ const GARequestDimension({required this.name});
+
+ /// Creates a [GARequestDimension] from JSON data.
+ factory GARequestDimension.fromJson(Map json) =>
+ _$GARequestDimensionFromJson(json);
+
+ /// The name of the dimension.
+ final String name;
+
+ /// Converts this [GARequestDimension] instance to JSON data.
+ Map toJson() => _$GARequestDimensionToJson(this);
+
+ @override
+ @JsonKey(includeToJson: false)
+ List get props => [name];
+}
+
+/// {@template ga_request_metric}
+/// Represents a metric to include in a Google Analytics report request.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ includeIfNull: false,
+ checked: true,
+)
+class GARequestMetric extends Equatable {
+ /// {@macro ga_request_metric}
+ const GARequestMetric({required this.name});
+
+ /// Creates a [GARequestMetric] from JSON data.
+ factory GARequestMetric.fromJson(Map json) =>
+ _$GARequestMetricFromJson(json);
+
+ /// The name of the metric.
+ final String name;
+
+ /// Converts this [GARequestMetric] instance to JSON data.
+ Map toJson() => _$GARequestMetricToJson(this);
+
+ @override
+ @JsonKey(includeToJson: false)
+ List get props => [name];
+}
+
+/// {@template ga_request_filter_expression}
+/// Represents a filter expression for a Google Analytics report request.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ includeIfNull: false,
+ checked: true,
+)
+class GARequestFilterExpression extends Equatable {
+ /// {@macro ga_request_filter_expression}
+ const GARequestFilterExpression({required this.filter});
+
+ /// Creates a [GARequestFilterExpression] from JSON data.
+ factory GARequestFilterExpression.fromJson(Map json) =>
+ _$GARequestFilterExpressionFromJson(json);
+
+ /// The filter to apply.
+ final GARequestFilter filter;
+
+ /// Converts this [GARequestFilterExpression] instance to JSON data.
+ Map toJson() => _$GARequestFilterExpressionToJson(this);
+
+ @override
+ @JsonKey(includeToJson: false)
+ List get props => [filter];
+}
+
+/// {@template ga_request_filter}
+/// Represents a filter for a specific field in a Google Analytics request.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ includeIfNull: false,
+ checked: true,
+)
+class GARequestFilter extends Equatable {
+ /// {@macro ga_request_filter}
+ const GARequestFilter({required this.fieldName, required this.stringFilter});
+
+ /// Creates a [GARequestFilter] from JSON data.
+ factory GARequestFilter.fromJson(Map json) =>
+ _$GARequestFilterFromJson(json);
+
+ /// The name of the field to filter on.
+ final String fieldName;
+
+ /// The string filter to apply.
+ final GARequestStringFilter stringFilter;
+
+ /// Converts this [GARequestFilter] instance to JSON data.
+ Map toJson() => _$GARequestFilterToJson(this);
+
+ @override
+ @JsonKey(includeToJson: false)
+ List get props => [fieldName, stringFilter];
+}
+
+/// {@template ga_request_string_filter}
+/// Represents a string filter in a Google Analytics request.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ includeIfNull: false,
+ checked: true,
+)
+class GARequestStringFilter extends Equatable {
+ /// {@macro ga_request_string_filter}
+ const GARequestStringFilter({required this.value});
+
+ /// Creates a [GARequestStringFilter] from JSON data.
+ factory GARequestStringFilter.fromJson(Map json) =>
+ _$GARequestStringFilterFromJson(json);
+
+ /// The value to filter by.
+ final String value;
+
+ /// Converts this [GARequestStringFilter] instance to JSON data.
+ Map toJson() => _$GARequestStringFilterToJson(this);
+
+ @override
+ @JsonKey(includeToJson: false)
+ List get props => [value];
+}
diff --git a/lib/src/models/analytics/google_analytics_request.g.dart b/lib/src/models/analytics/google_analytics_request.g.dart
new file mode 100644
index 00000000..50071de9
--- /dev/null
+++ b/lib/src/models/analytics/google_analytics_request.g.dart
@@ -0,0 +1,133 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'google_analytics_request.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+RunReportRequest _$RunReportRequestFromJson(
+ Map json,
+) => $checkedCreate('RunReportRequest', json, ($checkedConvert) {
+ final val = RunReportRequest(
+ dateRanges: $checkedConvert(
+ 'dateRanges',
+ (v) => (v as List)
+ .map((e) => GARequestDateRange.fromJson(e as Map))
+ .toList(),
+ ),
+ dimensions: $checkedConvert(
+ 'dimensions',
+ (v) => (v as List?)
+ ?.map((e) => GARequestDimension.fromJson(e as Map))
+ .toList(),
+ ),
+ metrics: $checkedConvert(
+ 'metrics',
+ (v) => (v as List?)
+ ?.map((e) => GARequestMetric.fromJson(e as Map))
+ .toList(),
+ ),
+ dimensionFilter: $checkedConvert(
+ 'dimensionFilter',
+ (v) => v == null
+ ? null
+ : GARequestFilterExpression.fromJson(v as Map),
+ ),
+ limit: $checkedConvert('limit', (v) => (v as num?)?.toInt()),
+ );
+ return val;
+});
+
+Map _$RunReportRequestToJson(RunReportRequest instance) =>
+ {
+ 'dateRanges': instance.dateRanges.map((e) => e.toJson()).toList(),
+ 'dimensions': ?instance.dimensions?.map((e) => e.toJson()).toList(),
+ 'metrics': ?instance.metrics?.map((e) => e.toJson()).toList(),
+ 'dimensionFilter': ?instance.dimensionFilter?.toJson(),
+ 'limit': ?instance.limit,
+ };
+
+GARequestDateRange _$GARequestDateRangeFromJson(Map json) =>
+ $checkedCreate('GARequestDateRange', json, ($checkedConvert) {
+ final val = GARequestDateRange(
+ startDate: $checkedConvert('startDate', (v) => v as String),
+ endDate: $checkedConvert('endDate', (v) => v as String),
+ );
+ return val;
+ });
+
+Map _$GARequestDateRangeToJson(GARequestDateRange instance) =>
+ {
+ 'startDate': instance.startDate,
+ 'endDate': instance.endDate,
+ };
+
+GARequestDimension _$GARequestDimensionFromJson(Map json) =>
+ $checkedCreate('GARequestDimension', json, ($checkedConvert) {
+ final val = GARequestDimension(
+ name: $checkedConvert('name', (v) => v as String),
+ );
+ return val;
+ });
+
+Map _$GARequestDimensionToJson(GARequestDimension instance) =>
+ {'name': instance.name};
+
+GARequestMetric _$GARequestMetricFromJson(Map json) =>
+ $checkedCreate('GARequestMetric', json, ($checkedConvert) {
+ final val = GARequestMetric(
+ name: $checkedConvert('name', (v) => v as String),
+ );
+ return val;
+ });
+
+Map _$GARequestMetricToJson(GARequestMetric instance) =>
+ {'name': instance.name};
+
+GARequestFilterExpression _$GARequestFilterExpressionFromJson(
+ Map json,
+) => $checkedCreate('GARequestFilterExpression', json, ($checkedConvert) {
+ final val = GARequestFilterExpression(
+ filter: $checkedConvert(
+ 'filter',
+ (v) => GARequestFilter.fromJson(v as Map),
+ ),
+ );
+ return val;
+});
+
+Map _$GARequestFilterExpressionToJson(
+ GARequestFilterExpression instance,
+) => {'filter': instance.filter.toJson()};
+
+GARequestFilter _$GARequestFilterFromJson(Map json) =>
+ $checkedCreate('GARequestFilter', json, ($checkedConvert) {
+ final val = GARequestFilter(
+ fieldName: $checkedConvert('fieldName', (v) => v as String),
+ stringFilter: $checkedConvert(
+ 'stringFilter',
+ (v) => GARequestStringFilter.fromJson(v as Map),
+ ),
+ );
+ return val;
+ });
+
+Map _$GARequestFilterToJson(GARequestFilter instance) =>
+ {
+ 'fieldName': instance.fieldName,
+ 'stringFilter': instance.stringFilter.toJson(),
+ };
+
+GARequestStringFilter _$GARequestStringFilterFromJson(
+ Map json,
+) => $checkedCreate('GARequestStringFilter', json, ($checkedConvert) {
+ final val = GARequestStringFilter(
+ value: $checkedConvert('value', (v) => v as String),
+ );
+ return val;
+});
+
+Map _$GARequestStringFilterToJson(
+ GARequestStringFilter instance,
+) => {'value': instance.value};
diff --git a/lib/src/models/analytics/google_analytics_response.dart b/lib/src/models/analytics/google_analytics_response.dart
new file mode 100644
index 00000000..37624b7f
--- /dev/null
+++ b/lib/src/models/analytics/google_analytics_response.dart
@@ -0,0 +1,101 @@
+import 'package:equatable/equatable.dart';
+import 'package:json_annotation/json_annotation.dart';
+
+part 'google_analytics_response.g.dart';
+
+/// {@template run_report_response}
+/// Represents the response from the Google Analytics Data API's `runReport`.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ createToJson: false,
+ checked: true,
+)
+class RunReportResponse extends Equatable {
+ /// {@macro run_report_response}
+ const RunReportResponse({this.rows});
+
+ /// Creates a [RunReportResponse] from JSON data.
+ factory RunReportResponse.fromJson(Map json) =>
+ _$RunReportResponseFromJson(json);
+
+ /// The data rows from the report. Can be null if no data is returned.
+ final List? rows;
+
+ @override
+ List get props => [rows];
+}
+
+/// {@template ga_row}
+/// Represents a single row of data in a Google Analytics report.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ createToJson: false,
+ checked: true,
+)
+class GARow extends Equatable {
+ /// {@macro ga_row}
+ const GARow({required this.metricValues, required this.dimensionValues});
+
+ /// Creates a [GARow] from JSON data.
+ factory GARow.fromJson(Map json) => _$GARowFromJson(json);
+
+ /// The values of the dimensions in this row.
+ @JsonKey(defaultValue: [])
+ final List dimensionValues;
+
+ /// The values of the metrics in this row.
+ final List metricValues;
+
+ @override
+ List get props => [dimensionValues, metricValues];
+}
+
+/// {@template ga_dimension_value}
+/// Represents the value of a single dimension.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ createToJson: false,
+ checked: true,
+)
+class GADimensionValue extends Equatable {
+ /// {@macro ga_dimension_value}
+ const GADimensionValue({this.value});
+
+ /// Creates a [GADimensionValue] from JSON data.
+ factory GADimensionValue.fromJson(Map json) =>
+ _$GADimensionValueFromJson(json);
+
+ /// The string value of the dimension.
+ final String? value;
+
+ @override
+ List get props => [value];
+}
+
+/// {@template ga_metric_value}
+/// Represents the value of a single metric.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ createToJson: false,
+ checked: true,
+)
+class GAMetricValue extends Equatable {
+ /// {@macro ga_metric_value}
+ const GAMetricValue({this.value});
+
+ /// Creates a [GAMetricValue] from JSON data.
+ factory GAMetricValue.fromJson(Map json) =>
+ _$GAMetricValueFromJson(json);
+
+ /// The numeric value of the metric.
+ ///
+ /// It's a string in the API response, so it needs to be parsed.
+ final String? value;
+
+ @override
+ List get props => [value];
+}
diff --git a/lib/src/models/analytics/google_analytics_response.g.dart b/lib/src/models/analytics/google_analytics_response.g.dart
new file mode 100644
index 00000000..a2865441
--- /dev/null
+++ b/lib/src/models/analytics/google_analytics_response.g.dart
@@ -0,0 +1,59 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'google_analytics_response.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+RunReportResponse _$RunReportResponseFromJson(Map json) =>
+ $checkedCreate('RunReportResponse', json, ($checkedConvert) {
+ final val = RunReportResponse(
+ rows: $checkedConvert(
+ 'rows',
+ (v) => (v as List?)
+ ?.map((e) => GARow.fromJson(e as Map))
+ .toList(),
+ ),
+ );
+ return val;
+ });
+
+GARow _$GARowFromJson(Map json) =>
+ $checkedCreate('GARow', json, ($checkedConvert) {
+ final val = GARow(
+ metricValues: $checkedConvert(
+ 'metricValues',
+ (v) => (v as List)
+ .map((e) => GAMetricValue.fromJson(e as Map))
+ .toList(),
+ ),
+ dimensionValues: $checkedConvert(
+ 'dimensionValues',
+ (v) =>
+ (v as List?)
+ ?.map(
+ (e) => GADimensionValue.fromJson(e as Map),
+ )
+ .toList() ??
+ [],
+ ),
+ );
+ return val;
+ });
+
+GADimensionValue _$GADimensionValueFromJson(Map json) =>
+ $checkedCreate('GADimensionValue', json, ($checkedConvert) {
+ final val = GADimensionValue(
+ value: $checkedConvert('value', (v) => v as String?),
+ );
+ return val;
+ });
+
+GAMetricValue _$GAMetricValueFromJson(Map json) =>
+ $checkedCreate('GAMetricValue', json, ($checkedConvert) {
+ final val = GAMetricValue(
+ value: $checkedConvert('value', (v) => v as String?),
+ );
+ return val;
+ });
diff --git a/lib/src/models/analytics/mixpanel_request.dart b/lib/src/models/analytics/mixpanel_request.dart
new file mode 100644
index 00000000..25fac646
--- /dev/null
+++ b/lib/src/models/analytics/mixpanel_request.dart
@@ -0,0 +1,103 @@
+import 'package:equatable/equatable.dart';
+import 'package:json_annotation/json_annotation.dart';
+
+part 'mixpanel_request.g.dart';
+
+/// The time unit for segmenting data in Mixpanel.
+enum MixpanelTimeUnit {
+ /// Segment data by the hour.
+ hour,
+
+ /// Segment data by the day.
+ day,
+
+ /// Segment data by the week.
+ week,
+
+ /// Segment data by the month.
+ month,
+}
+
+/// {@template mixpanel_segmentation_request}
+/// Represents the query parameters for a Mixpanel segmentation request.
+/// {@endtemplate}
+@JsonSerializable(fieldRename: FieldRename.snake)
+class MixpanelSegmentationRequest extends Equatable {
+ /// {@macro mixpanel_segmentation_request}
+ const MixpanelSegmentationRequest({
+ required this.projectId,
+ required this.event,
+ required this.fromDate,
+ required this.toDate,
+ this.unit,
+ });
+
+ /// Creates a [MixpanelSegmentationRequest] from a JSON object.
+ factory MixpanelSegmentationRequest.fromJson(Map json) =>
+ _$MixpanelSegmentationRequestFromJson(json);
+
+ /// The ID of the Mixpanel project.
+ final String projectId;
+
+ /// The name of the event to segment.
+ final String event;
+
+ /// The start date in 'YYYY-MM-DD' format.
+ final String fromDate;
+
+ /// The end date in 'YYYY-MM-DD' format.
+ final String toDate;
+
+ /// The time unit for segmentation (e.g., 'day', 'week').
+ final MixpanelTimeUnit? unit;
+
+ /// Converts this instance to a JSON map for query parameters.
+ Map toJson() => _$MixpanelSegmentationRequestToJson(this);
+
+ @override
+ List get props => [projectId, event, fromDate, toDate, unit];
+}
+
+/// {@template mixpanel_top_events_request}
+/// Represents the query parameters for a Mixpanel top events/properties request.
+/// {@endtemplate}
+@JsonSerializable(fieldRename: FieldRename.snake)
+class MixpanelTopEventsRequest extends Equatable {
+ /// {@macro mixpanel_top_events_request}
+ const MixpanelTopEventsRequest({
+ required this.projectId,
+ required this.event,
+ required this.name,
+ required this.fromDate,
+ required this.toDate,
+ required this.limit,
+ });
+
+ /// Creates a [MixpanelTopEventsRequest] from a JSON object.
+ factory MixpanelTopEventsRequest.fromJson(Map json) =>
+ _$MixpanelTopEventsRequestFromJson(json);
+
+ /// The ID of the Mixpanel project.
+ final String projectId;
+
+ /// The name of the event to analyze.
+ final String event;
+
+ /// The name of the property to get top values for.
+ final String name;
+
+ /// The start date in 'YYYY-MM-dd' format.
+ final String fromDate;
+
+ /// The end date in 'YYYY-MM-dd' format.
+ final String toDate;
+
+ /// The maximum number of property values to return.
+ final int limit;
+
+ /// Converts this instance to a JSON map for query parameters.
+ Map toJson() => _$MixpanelTopEventsRequestToJson(this);
+
+ @override
+ List get props => [projectId, event, name, fromDate, toDate, limit];
+}
diff --git a/lib/src/models/analytics/mixpanel_request.g.dart b/lib/src/models/analytics/mixpanel_request.g.dart
new file mode 100644
index 00000000..2dbf9af6
--- /dev/null
+++ b/lib/src/models/analytics/mixpanel_request.g.dart
@@ -0,0 +1,56 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'mixpanel_request.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+MixpanelSegmentationRequest _$MixpanelSegmentationRequestFromJson(
+ Map json,
+) => MixpanelSegmentationRequest(
+ projectId: json['project_id'] as String,
+ event: json['event'] as String,
+ fromDate: json['from_date'] as String,
+ toDate: json['to_date'] as String,
+ unit: $enumDecodeNullable(_$MixpanelTimeUnitEnumMap, json['unit']),
+);
+
+Map _$MixpanelSegmentationRequestToJson(
+ MixpanelSegmentationRequest instance,
+) => {
+ 'project_id': instance.projectId,
+ 'event': instance.event,
+ 'from_date': instance.fromDate,
+ 'to_date': instance.toDate,
+ 'unit': _$MixpanelTimeUnitEnumMap[instance.unit],
+};
+
+const _$MixpanelTimeUnitEnumMap = {
+ MixpanelTimeUnit.hour: 'hour',
+ MixpanelTimeUnit.day: 'day',
+ MixpanelTimeUnit.week: 'week',
+ MixpanelTimeUnit.month: 'month',
+};
+
+MixpanelTopEventsRequest _$MixpanelTopEventsRequestFromJson(
+ Map json,
+) => MixpanelTopEventsRequest(
+ projectId: json['project_id'] as String,
+ event: json['event'] as String,
+ name: json['name'] as String,
+ fromDate: json['from_date'] as String,
+ toDate: json['to_date'] as String,
+ limit: (json['limit'] as num).toInt(),
+);
+
+Map _$MixpanelTopEventsRequestToJson(
+ MixpanelTopEventsRequest instance,
+) => {
+ 'project_id': instance.projectId,
+ 'event': instance.event,
+ 'name': instance.name,
+ 'from_date': instance.fromDate,
+ 'to_date': instance.toDate,
+ 'limit': instance.limit,
+};
diff --git a/lib/src/models/analytics/mixpanel_response.dart b/lib/src/models/analytics/mixpanel_response.dart
new file mode 100644
index 00000000..1d213967
--- /dev/null
+++ b/lib/src/models/analytics/mixpanel_response.dart
@@ -0,0 +1,57 @@
+import 'package:equatable/equatable.dart';
+import 'package:json_annotation/json_annotation.dart';
+
+part 'mixpanel_response.g.dart';
+
+/// {@template mixpanel_response}
+/// A generic response wrapper for Mixpanel API calls.
+/// {@endtemplate}
+@JsonSerializable(
+ genericArgumentFactories: true,
+ explicitToJson: true,
+ createToJson: false,
+ checked: true,
+)
+class MixpanelResponse extends Equatable {
+ /// {@macro mixpanel_response}
+ const MixpanelResponse({required this.data});
+
+ /// Creates a [MixpanelResponse] from JSON data.
+ factory MixpanelResponse.fromJson(
+ Map json,
+ T Function(Object? json) fromJsonT,
+ ) => _$MixpanelResponseFromJson(json, fromJsonT);
+
+ /// The main data payload of the response.
+ final T data;
+
+ @override
+ List get props => [data];
+}
+
+/// {@template mixpanel_segmentation_data}
+/// Represents the data structure for a Mixpanel segmentation query.
+/// {@endtemplate}
+@JsonSerializable(
+ explicitToJson: true,
+ createToJson: false,
+ checked: true,
+)
+class MixpanelSegmentationData extends Equatable {
+ /// {@macro mixpanel_segmentation_data}
+ const MixpanelSegmentationData({required this.series, required this.values});
+
+ /// Creates a [MixpanelSegmentationData] from JSON data.
+ factory MixpanelSegmentationData.fromJson(Map json) =>
+ _$MixpanelSegmentationDataFromJson(json);
+
+ /// A list of date strings representing the time series.
+ final List series;
+
+ /// A map where keys are segment names and values are lists of metrics
+ /// corresponding to the `series` dates.
+ final Map> values;
+
+ @override
+ List get props => [series, values];
+}
diff --git a/lib/src/models/analytics/mixpanel_response.g.dart b/lib/src/models/analytics/mixpanel_response.g.dart
new file mode 100644
index 00000000..bdbbfbcb
--- /dev/null
+++ b/lib/src/models/analytics/mixpanel_response.g.dart
@@ -0,0 +1,38 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'mixpanel_response.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+MixpanelResponse _$MixpanelResponseFromJson(
+ Map json,
+ T Function(Object? json) fromJsonT,
+) => $checkedCreate('MixpanelResponse', json, ($checkedConvert) {
+ final val = MixpanelResponse(
+ data: $checkedConvert('data', (v) => fromJsonT(v)),
+ );
+ return val;
+});
+
+MixpanelSegmentationData _$MixpanelSegmentationDataFromJson(
+ Map json,
+) => $checkedCreate('MixpanelSegmentationData', json, ($checkedConvert) {
+ final val = MixpanelSegmentationData(
+ series: $checkedConvert(
+ 'series',
+ (v) => (v as List).map((e) => e as String).toList(),
+ ),
+ values: $checkedConvert(
+ 'values',
+ (v) => (v as Map).map(
+ (k, e) => MapEntry(
+ k,
+ (e as List).map((e) => (e as num).toInt()).toList(),
+ ),
+ ),
+ ),
+ );
+ return val;
+});
diff --git a/lib/src/models/models.dart b/lib/src/models/models.dart
index 97f2ceaa..960b2c25 100644
--- a/lib/src/models/models.dart
+++ b/lib/src/models/models.dart
@@ -1,2 +1,3 @@
+export 'analytics/analytics.dart';
export 'push_notification/push_notification.dart';
export 'request_id.dart';
diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart
index 69e00e79..3f551dfa 100644
--- a/lib/src/rbac/permissions.dart
+++ b/lib/src/rbac/permissions.dart
@@ -133,4 +133,7 @@ abstract class Permissions {
/// Allows updating the user's own app review record.
static const String appReviewUpdateOwned = 'app_review.update_owned';
+
+ // Analytics Permissions
+ static const String analyticsRead = 'analytics.read';
}
diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart
index 466a582e..4d06ed4f 100644
--- a/lib/src/rbac/role_permissions.dart
+++ b/lib/src/rbac/role_permissions.dart
@@ -102,6 +102,9 @@ final Set _dashboardAdminPermissions = {
Permissions.remoteConfigUpdate,
Permissions.remoteConfigDelete,
Permissions.userPreferenceBypassLimits,
+
+ // Analytics
+ Permissions.analyticsRead,
};
/// Defines the mapping between user roles (both app and dashboard) and the
diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart
index a0533a71..23f44726 100644
--- a/lib/src/registry/data_operation_registry.dart
+++ b/lib/src/registry/data_operation_registry.dart
@@ -7,7 +7,6 @@ import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/own
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/rbac/permissions.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_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/push_notification_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/user_action_limit_service.dart';
import 'package:logging/logging.dart';
@@ -120,8 +119,6 @@ class DataOperationRegistry {
.read(id: id, userId: null),
'remote_config': (c, id) =>
c.read>().read(id: id, userId: null),
- 'dashboard_summary': (c, id) =>
- c.read().getSummary(),
'in_app_notification': (c, id) => c
.read>()
.read(id: id, userId: null),
@@ -134,6 +131,13 @@ class DataOperationRegistry {
c.read>().read(id: id, userId: null),
'app_review': (c, id) =>
c.read>().read(id: id, userId: null),
+ 'kpi_card_data': (c, id) =>
+ c.read>().read(id: id, userId: null),
+ 'chart_card_data': (c, id) =>
+ c.read>().read(id: id, userId: null),
+ 'ranked_list_card_data': (c, id) => c
+ .read>()
+ .read(id: id, userId: null),
});
// --- Register "Read All" Readers ---
@@ -217,6 +221,27 @@ class DataOperationRegistry {
sort: s,
pagination: p,
),
+ 'kpi_card_data': (c, uid, f, s, p) =>
+ c.read>().readAll(
+ userId: uid,
+ filter: f,
+ sort: s,
+ pagination: p,
+ ),
+ 'chart_card_data': (c, uid, f, s, p) =>
+ c.read>().readAll(
+ userId: uid,
+ filter: f,
+ sort: s,
+ pagination: p,
+ ),
+ 'ranked_list_card_data': (c, uid, f, s, p) =>
+ c.read>().readAll(
+ userId: uid,
+ filter: f,
+ sort: s,
+ pagination: p,
+ ),
});
// --- Register Item Creators ---
diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart
index 28d504b0..7c02dcb9 100644
--- a/lib/src/registry/model_registry.dart
+++ b/lib/src/registry/model_registry.dart
@@ -398,30 +398,70 @@ final modelRegistry = >{
requiresAuthentication: true,
),
),
- 'dashboard_summary': ModelConfig(
- fromJson: DashboardSummary.fromJson,
- getId: (summary) => summary.id,
- getOwnerId: null, // Not a user-owned resource
- // Permissions: Read-only for admins, all other actions unsupported.
+ 'kpi_card_data': ModelConfig(
+ fromJson: KpiCardData.fromJson,
+ getId: (d) => d.id.name,
+ getOwnerId: null, // System-owned resource
getCollectionPermission: const ModelActionPermission(
+ type: RequiredPermissionType.specificPermission,
+ permission: Permissions.analyticsRead,
+ ),
+ getItemPermission: const ModelActionPermission(
+ type: RequiredPermissionType.specificPermission,
+ permission: Permissions.analyticsRead,
+ ),
+ postPermission: const ModelActionPermission(
+ type: RequiredPermissionType.unsupported,
+ ),
+ putPermission: const ModelActionPermission(
type: RequiredPermissionType.unsupported,
- requiresAuthentication: true,
+ ),
+ deletePermission: const ModelActionPermission(
+ type: RequiredPermissionType.unsupported,
+ ),
+ ),
+ 'chart_card_data': ModelConfig(
+ fromJson: ChartCardData.fromJson,
+ getId: (d) => d.id.name,
+ getOwnerId: null, // System-owned resource
+ getCollectionPermission: const ModelActionPermission(
+ type: RequiredPermissionType.specificPermission,
+ permission: Permissions.analyticsRead,
),
getItemPermission: const ModelActionPermission(
- type: RequiredPermissionType.adminOnly,
- requiresAuthentication: true,
+ type: RequiredPermissionType.specificPermission,
+ permission: Permissions.analyticsRead,
+ ),
+ postPermission: const ModelActionPermission(
+ type: RequiredPermissionType.unsupported,
+ ),
+ putPermission: const ModelActionPermission(
+ type: RequiredPermissionType.unsupported,
+ ),
+ deletePermission: const ModelActionPermission(
+ type: RequiredPermissionType.unsupported,
+ ),
+ ),
+ 'ranked_list_card_data': ModelConfig(
+ fromJson: RankedListCardData.fromJson,
+ getId: (d) => d.id.name,
+ getOwnerId: null, // System-owned resource
+ getCollectionPermission: const ModelActionPermission(
+ type: RequiredPermissionType.specificPermission,
+ permission: Permissions.analyticsRead,
+ ),
+ getItemPermission: const ModelActionPermission(
+ type: RequiredPermissionType.specificPermission,
+ permission: Permissions.analyticsRead,
),
postPermission: const ModelActionPermission(
type: RequiredPermissionType.unsupported,
- requiresAuthentication: true,
),
putPermission: const ModelActionPermission(
type: RequiredPermissionType.unsupported,
- requiresAuthentication: true,
),
deletePermission: const ModelActionPermission(
type: RequiredPermissionType.unsupported,
- requiresAuthentication: true,
),
),
'push_notification_device': ModelConfig(
diff --git a/lib/src/services/analytics/analytics.dart b/lib/src/services/analytics/analytics.dart
new file mode 100644
index 00000000..42933304
--- /dev/null
+++ b/lib/src/services/analytics/analytics.dart
@@ -0,0 +1,6 @@
+export 'analytics_metric_mapper.dart';
+export 'analytics_query_builder.dart';
+export 'analytics_reporting_client.dart';
+export 'analytics_sync_service.dart';
+export 'google_analytics_data_client.dart';
+export 'mixpanel_data_client.dart';
diff --git a/lib/src/services/analytics/analytics_metric_mapper.dart b/lib/src/services/analytics/analytics_metric_mapper.dart
new file mode 100644
index 00000000..b6075e07
--- /dev/null
+++ b/lib/src/services/analytics/analytics_metric_mapper.dart
@@ -0,0 +1,198 @@
+import 'package:core/core.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/models/models.dart';
+
+/// {@template analytics_metric_mapper}
+/// Maps internal analytics card IDs to structured [AnalyticsQuery] objects.
+///
+/// This centralizes the "dictionary" of what to query for each card,
+/// decoupling the sync service from the implementation details of each
+/// analytics provider.
+/// {@endtemplate}
+class AnalyticsMetricMapper {
+ /// Returns the query object for a given KPI card.
+ MetricQuery? getKpiQuery(KpiCardId kpiId) {
+ return _kpiQueryMappings[kpiId];
+ }
+
+ /// Returns the query object for a given chart card.
+ MetricQuery? getChartQuery(ChartCardId chartId) {
+ return _chartQueryMappings[chartId];
+ }
+
+ /// Returns the query object for a ranked list.
+ AnalyticsQuery? getRankedListQuery(
+ RankedListCardId rankedListId,
+ ) {
+ return _rankedListQueryMappings[rankedListId];
+ }
+
+ static final Map _kpiQueryMappings = {
+ // User KPIs
+ KpiCardId.usersTotalRegistered: const EventCountQuery(
+ event: AnalyticsEvent.userRegistered,
+ ),
+ KpiCardId.usersNewRegistrations: const EventCountQuery(
+ event: AnalyticsEvent.userRegistered,
+ ),
+ KpiCardId.usersActiveUsers: const StandardMetricQuery(
+ metric: 'activeUsers',
+ ),
+ // Headline KPIs
+ KpiCardId.contentHeadlinesTotalPublished: const StandardMetricQuery(
+ metric: 'database:headlines:count',
+ ),
+ KpiCardId.contentHeadlinesTotalViews: const EventCountQuery(
+ event: AnalyticsEvent.contentViewed,
+ ),
+ KpiCardId.contentHeadlinesTotalLikes: const EventCountQuery(
+ event: AnalyticsEvent.reactionCreated,
+ ),
+ // Source KPIs
+ KpiCardId.contentSourcesTotalSources: const StandardMetricQuery(
+ metric: 'database:sources:count',
+ ),
+ KpiCardId.contentSourcesNewSources: const StandardMetricQuery(
+ metric: 'database:sources:count',
+ ),
+ KpiCardId.contentSourcesTotalFollowers: const StandardMetricQuery(
+ metric: 'database:sources:followers',
+ ),
+ // Topic KPIs
+ KpiCardId.contentTopicsTotalTopics: const StandardMetricQuery(
+ metric: 'database:topics:count',
+ ),
+ KpiCardId.contentTopicsNewTopics: const StandardMetricQuery(
+ metric: 'database:topics:count',
+ ),
+ KpiCardId.contentTopicsTotalFollowers: const StandardMetricQuery(
+ metric: 'database:topics:followers',
+ ),
+ // Engagement KPIs
+ KpiCardId.engagementsTotalReactions: const EventCountQuery(
+ event: AnalyticsEvent.reactionCreated,
+ ),
+ KpiCardId.engagementsTotalComments: const EventCountQuery(
+ event: AnalyticsEvent.commentCreated,
+ ),
+ KpiCardId.engagementsAverageEngagementRate: const StandardMetricQuery(
+ metric: 'calculated:engagementRate',
+ ),
+ // Report KPIs
+ KpiCardId.engagementsReportsPending: const StandardMetricQuery(
+ metric: 'database:reports:pending',
+ ),
+ KpiCardId.engagementsReportsResolved: const StandardMetricQuery(
+ metric: 'database:reports:resolved',
+ ),
+ KpiCardId.engagementsReportsAverageResolutionTime:
+ const StandardMetricQuery(
+ metric: 'database:reports:avgResolutionTime',
+ ),
+ // App Review KPIs
+ KpiCardId.engagementsAppReviewsTotalFeedback: const EventCountQuery(
+ event: AnalyticsEvent.appReviewPromptResponded,
+ ),
+ KpiCardId.engagementsAppReviewsPositiveFeedback: const EventCountQuery(
+ event: AnalyticsEvent.appReviewPromptResponded,
+ ),
+ KpiCardId.engagementsAppReviewsStoreRequests: const EventCountQuery(
+ event: AnalyticsEvent.appReviewStoreRequested,
+ ),
+ };
+
+ static final Map _chartQueryMappings = {
+ // User Charts
+ ChartCardId.usersRegistrationsOverTime: const EventCountQuery(
+ event: AnalyticsEvent.userRegistered,
+ ),
+ ChartCardId.usersActiveUsersOverTime: const StandardMetricQuery(
+ metric: 'activeUsers',
+ ),
+ ChartCardId.usersRoleDistribution: const StandardMetricQuery(
+ metric: 'database:users:userRoleDistribution',
+ ),
+ // Headline Charts
+ ChartCardId.contentHeadlinesViewsOverTime: const EventCountQuery(
+ event: AnalyticsEvent.contentViewed,
+ ),
+ ChartCardId.contentHeadlinesLikesOverTime: const EventCountQuery(
+ event: AnalyticsEvent.reactionCreated,
+ ),
+ ChartCardId.contentHeadlinesViewsByTopic: const StandardMetricQuery(
+ metric: 'database:headlines:viewsByTopic',
+ ),
+ // Sources Tab
+ ChartCardId.contentSourcesHeadlinesPublishedOverTime:
+ const StandardMetricQuery(
+ metric: 'database:headlines:bySource',
+ ),
+ ChartCardId.contentSourcesEngagementByType: const StandardMetricQuery(
+ metric: 'database:sources:engagementByType',
+ ),
+ ChartCardId.contentSourcesStatusDistribution: const StandardMetricQuery(
+ metric: 'database:sources:statusDistribution',
+ ),
+ // Topics Tab
+ ChartCardId.contentTopicsHeadlinesPublishedOverTime:
+ const StandardMetricQuery(
+ metric: 'database:headlines:byTopic',
+ ),
+ ChartCardId.contentTopicsEngagementByTopic: const StandardMetricQuery(
+ metric: 'database:headlines:topicEngagement',
+ ),
+ ChartCardId.contentHeadlinesBreakingNewsDistribution:
+ const StandardMetricQuery(
+ metric: 'database:headlines:breakingNewsDistribution',
+ ),
+ // Engagements Tab
+ ChartCardId.engagementsReactionsOverTime: const EventCountQuery(
+ event: AnalyticsEvent.reactionCreated,
+ ),
+ ChartCardId.engagementsCommentsOverTime: const EventCountQuery(
+ event: AnalyticsEvent.commentCreated,
+ ),
+ ChartCardId.engagementsReactionsByType: const StandardMetricQuery(
+ metric: 'database:engagements:reactionsByType',
+ ),
+ // Reports Tab
+ ChartCardId.engagementsReportsSubmittedOverTime: const EventCountQuery(
+ event: AnalyticsEvent.reportSubmitted,
+ ),
+ ChartCardId.engagementsReportsResolutionTimeOverTime:
+ const StandardMetricQuery(metric: 'database:reports:avgResolutionTime'),
+ ChartCardId.engagementsReportsByReason: const StandardMetricQuery(
+ metric: 'database:reports:byReason',
+ ),
+ // App Reviews Tab
+ ChartCardId.engagementsAppReviewsFeedbackOverTime: const EventCountQuery(
+ event: AnalyticsEvent.appReviewPromptResponded,
+ ),
+ ChartCardId.engagementsAppReviewsPositiveVsNegative:
+ const StandardMetricQuery(
+ metric: 'database:app_reviews:feedback',
+ ),
+ ChartCardId.engagementsAppReviewsStoreRequestsOverTime:
+ const EventCountQuery(
+ event: AnalyticsEvent.appReviewStoreRequested,
+ ),
+ };
+
+ static final Map _rankedListQueryMappings =
+ {
+ RankedListCardId.overviewHeadlinesMostViewed: const RankedListQuery(
+ event: AnalyticsEvent.contentViewed,
+ dimension: 'contentId',
+ ),
+ RankedListCardId.overviewHeadlinesMostLiked: const RankedListQuery(
+ event: AnalyticsEvent.reactionCreated,
+ dimension: 'contentId',
+ ),
+ // These require database-only aggregations.
+ RankedListCardId.overviewSourcesMostFollowed: const StandardMetricQuery(
+ metric: 'database:sources:byFollowers',
+ ),
+ RankedListCardId.overviewTopicsMostFollowed: const StandardMetricQuery(
+ metric: 'database:topics:byFollowers',
+ ),
+ };
+}
diff --git a/lib/src/services/analytics/analytics_query_builder.dart b/lib/src/services/analytics/analytics_query_builder.dart
new file mode 100644
index 00000000..b720e16a
--- /dev/null
+++ b/lib/src/services/analytics/analytics_query_builder.dart
@@ -0,0 +1,339 @@
+import 'package:flutter_news_app_api_server_full_source_code/src/models/analytics/analytics.dart';
+import 'package:logging/logging.dart';
+
+/// {@template analytics_query_builder}
+/// A builder class responsible for creating complex MongoDB aggregation
+/// pipelines for analytics queries.
+///
+/// This class centralizes the query logic, decoupling the
+/// `AnalyticsSyncService` from the specific implementation details of
+/// database aggregations.
+/// {@endtemplate}
+class AnalyticsQueryBuilder {
+ final _log = Logger('AnalyticsQueryBuilder');
+
+ /// Creates a MongoDB aggregation pipeline for a given database metric.
+ ///
+ /// Returns `null` if the metric is not a supported database query.
+ List>? buildPipelineForMetric(
+ StandardMetricQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ ) {
+ final metric = query.metric;
+ _log.finer('Building pipeline for database metric: "$metric".');
+
+ switch (metric) {
+ case 'database:users:userRoleDistribution':
+ _log.info('Building user role distribution pipeline.');
+ return _buildUserRoleDistributionPipeline();
+ case 'database:reports:byReason':
+ _log.info(
+ 'Building reports by reason pipeline from $startDate to $endDate.',
+ );
+ return _buildReportsByReasonPipeline(startDate, endDate);
+ case 'database:engagements:reactionsByType':
+ _log.info(
+ 'Building reactions by type pipeline from $startDate to $endDate.',
+ );
+ return _buildReactionsByTypePipeline(startDate, endDate);
+ case 'database:app_reviews:feedback':
+ _log.info(
+ 'Building app review feedback pipeline from $startDate to $endDate.',
+ );
+ return _buildAppReviewFeedbackPipeline(startDate, endDate);
+ case 'database:reports:avgResolutionTime':
+ return _buildAvgReportResolutionTimePipeline(startDate, endDate);
+ case 'database:headlines:viewsByTopic':
+ return _buildCategoricalCountPipeline(
+ collection: 'headlines',
+ dateField: 'createdAt',
+ groupByField: r'$topic.name',
+ startDate: startDate,
+ endDate: endDate,
+ );
+ case 'database:headlines:bySource':
+ return _buildCategoricalCountPipeline(
+ collection: 'headlines',
+ dateField: 'createdAt',
+ groupByField: r'$source.name',
+ startDate: startDate,
+ endDate: endDate,
+ );
+ case 'database:sources:engagementByType':
+ return _buildCategoricalCountPipeline(
+ collection: 'sources',
+ dateField: 'createdAt',
+ groupByField: r'$sourceType',
+ startDate: startDate,
+ endDate: endDate,
+ );
+ case 'database:headlines:byTopic':
+ return _buildCategoricalCountPipeline(
+ collection: 'headlines',
+ dateField: 'createdAt',
+ groupByField: r'$topic.name',
+ startDate: startDate,
+ endDate: endDate,
+ );
+ case 'database:headlines:topicEngagement':
+ return _buildCategoricalCountPipeline(
+ collection: 'headlines',
+ dateField: 'createdAt',
+ groupByField: r'$topic.name',
+ startDate: startDate,
+ endDate: endDate,
+ );
+ case 'database:sources:statusDistribution':
+ _log.info(
+ 'Building categorical count pipeline for source status distribution.',
+ );
+ return _buildCategoricalCountPipeline(
+ collection: 'sources',
+ dateField: 'createdAt',
+ groupByField: r'$status',
+ startDate: startDate,
+ endDate: endDate,
+ );
+ case 'database:headlines:breakingNewsDistribution':
+ _log.info(
+ 'Building categorical count pipeline for breaking news distribution.',
+ );
+ return _buildCategoricalCountPipeline(
+ collection: 'headlines',
+ dateField: 'createdAt',
+ groupByField: r'$isBreaking',
+ startDate: startDate,
+ endDate: endDate,
+ );
+ // Ranked List Queries
+ case 'database:sources:byFollowers':
+ _log.info('Building ranked list pipeline for sources by followers.');
+ return _buildRankedByFollowersPipeline('sources');
+ case 'database:topics:byFollowers':
+ _log.info('Building ranked list pipeline for topics by followers.');
+ return _buildRankedByFollowersPipeline('topics');
+
+ default:
+ // This case is intentionally left to return null for metrics that
+ // are not categorical and are handled by other methods, like simple
+ // counts.
+ return null;
+ }
+ }
+
+ /// Creates a pipeline for user role distribution.
+ /// This is a snapshot and does not use a date filter.
+ List> _buildUserRoleDistributionPipeline() {
+ return [
+ {
+ r'$group': {
+ '_id': r'$appRole',
+ 'count': {r'$sum': 1},
+ },
+ },
+ {
+ r'$project': {'label': r'$_id', 'value': r'$count', '_id': 0},
+ },
+ ];
+ }
+
+ /// Creates a pipeline for reports grouped by reason within a date range.
+ List> _buildReportsByReasonPipeline(
+ DateTime startDate,
+ DateTime endDate,
+ ) {
+ return [
+ {
+ r'$match': {
+ 'createdAt': {
+ r'$gte': startDate.toUtc().toIso8601String(),
+ r'$lt': endDate.toUtc().toIso8601String(),
+ },
+ },
+ },
+ {
+ r'$group': {
+ '_id': r'$reason',
+ 'count': {r'$sum': 1},
+ },
+ },
+ {
+ r'$project': {'label': r'$_id', 'value': r'$count', '_id': 0},
+ },
+ ];
+ }
+
+ /// Creates a pipeline for reactions grouped by type within a date range.
+ List> _buildReactionsByTypePipeline(
+ DateTime startDate,
+ DateTime endDate,
+ ) {
+ return [
+ {
+ r'$match': {
+ 'createdAt': {
+ r'$gte': startDate.toUtc().toIso8601String(),
+ r'$lt': endDate.toUtc().toIso8601String(),
+ },
+ 'reaction': {r'$exists': true},
+ },
+ },
+ {
+ r'$group': {
+ '_id': r'$reaction.reactionType',
+ 'count': {r'$sum': 1},
+ },
+ },
+ {
+ r'$project': {'label': r'$_id', 'value': r'$count', '_id': 0},
+ },
+ ];
+ }
+
+ /// Creates a pipeline for app review feedback (positive vs. negative)
+ /// within a date range.
+ List> _buildAppReviewFeedbackPipeline(
+ DateTime startDate,
+ DateTime endDate,
+ ) {
+ return [
+ {
+ r'$match': {
+ 'createdAt': {
+ r'$gte': startDate.toUtc().toIso8601String(),
+ r'$lt': endDate.toUtc().toIso8601String(),
+ },
+ },
+ },
+ {
+ r'$group': {
+ '_id': r'$feedback',
+ 'count': {r'$sum': 1},
+ },
+ },
+ {
+ r'$project': {'label': r'$_id', 'value': r'$count', '_id': 0},
+ },
+ ];
+ }
+
+ /// Creates a pipeline for ranking items by follower count.
+ List> _buildRankedByFollowersPipeline(String model) {
+ // This pipeline calculates the number of followers for each document
+ // by getting the size of the `followerIds` array, sorts them,
+ // and projects them into the RankedListItem shape.
+ return [
+ {
+ r'$project': {
+ 'name': 1,
+ 'followerCount': {r'$size': r'$followerIds'},
+ },
+ },
+ {
+ r'$sort': {'followerCount': -1},
+ },
+ {r'$limit': 5},
+ {
+ r'$project': {
+ 'entityId': r'$_id',
+ 'displayTitle': r'$name',
+ 'metricValue': r'$followerCount',
+ '_id': 0,
+ },
+ },
+ ];
+ }
+
+ /// Creates a generic pipeline for counting occurrences of a categorical
+ /// field.
+ List> _buildCategoricalCountPipeline({
+ required String collection,
+ required String dateField,
+ required String groupByField,
+ required DateTime startDate,
+ required DateTime endDate,
+ }) {
+ return [
+ {
+ r'$match': {
+ dateField: {
+ r'$gte': startDate.toUtc().toIso8601String(),
+ r'$lt': endDate.toUtc().toIso8601String(),
+ },
+ },
+ },
+ {
+ r'$group': {
+ '_id': groupByField,
+ 'count': {r'$sum': 1},
+ },
+ },
+ {
+ r'$project': {
+ 'label': r'$_id',
+ 'value': r'$count',
+ '_id': 0,
+ },
+ },
+ ];
+ }
+
+ /// Creates a pipeline for calculating the average report resolution time.
+ List> _buildAvgReportResolutionTimePipeline(
+ DateTime startDate,
+ DateTime endDate,
+ ) {
+ _log.info(
+ 'Building average report resolution time pipeline from $startDate '
+ 'to $endDate.',
+ );
+ return [
+ // Match reports resolved within the date range
+ {
+ r'$match': {
+ 'status': 'resolved',
+ 'updatedAt': {
+ r'$gte': startDate.toUtc().toIso8601String(),
+ r'$lt': endDate.toUtc().toIso8601String(),
+ },
+ },
+ },
+ // Group by the date part of 'updatedAt'
+ {
+ r'$group': {
+ '_id': {
+ r'$dateToString': {
+ 'format': '%Y-%m-%d',
+ 'date': r'$updatedAt',
+ },
+ },
+ // Calculate the average difference between 'updatedAt' and
+ // 'createdAt' in milliseconds for each day.
+ 'avgResolutionTime': {
+ r'$avg': {
+ r'$subtract': [r'$updatedAt', r'$createdAt'],
+ },
+ },
+ },
+ },
+ // Convert the average time from milliseconds to hours
+ {
+ r'$project': {
+ 'label': r'$_id',
+ 'value': {
+ r'$divide': [
+ r'$avgResolutionTime',
+ 3600000, // 1000ms * 60s * 60m
+ ],
+ },
+ '_id': 0,
+ },
+ },
+ // Sort by date
+ {
+ r'$sort': {'label': 1},
+ },
+ ];
+ }
+}
diff --git a/lib/src/services/analytics/analytics_reporting_client.dart b/lib/src/services/analytics/analytics_reporting_client.dart
new file mode 100644
index 00000000..2f1ce3df
--- /dev/null
+++ b/lib/src/services/analytics/analytics_reporting_client.dart
@@ -0,0 +1,44 @@
+import 'package:core/core.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/models/models.dart';
+
+/// {@template analytics_reporting_client}
+/// An abstract interface for a client that fetches aggregated analytics data
+/// from a third-party provider.
+///
+/// This contract ensures that the `AnalyticsSyncService` can interact with
+/// different providers (like Google Analytics or Mixpanel) in a uniform way.
+/// {@endtemplate}
+abstract class AnalyticsReportingClient {
+ /// Fetches time-series data for a given metric.
+ ///
+ /// - [query]: The structured query object defining what to fetch.
+ /// - [startDate]: The start date for the time range.
+ /// - [endDate]: The end date for the time range.
+ ///
+ /// Returns a list of [DataPoint]s representing the metric's value over time.
+ Future> getTimeSeries(
+ MetricQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ );
+
+ /// Fetches a single metric value for a given time range.
+ ///
+ /// - [query]: The structured metric query object defining what to fetch.
+ /// - [startDate]: The start date for the time range.
+ /// - [endDate]: The end date for the time range.
+ ///
+ /// Returns the total value of the metric as a [num].
+ Future getMetricTotal(
+ MetricQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ );
+
+ /// Fetches a ranked list of items based on a metric.
+ Future> getRankedList(
+ RankedListQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ );
+}
diff --git a/lib/src/services/analytics/analytics_sync_service.dart b/lib/src/services/analytics/analytics_sync_service.dart
new file mode 100644
index 00000000..11e461ee
--- /dev/null
+++ b/lib/src/services/analytics/analytics_sync_service.dart
@@ -0,0 +1,602 @@
+import 'package:core/core.dart';
+import 'package:data_repository/data_repository.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/models/models.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/services/analytics/analytics.dart';
+import 'package:logging/logging.dart';
+
+/// {@template analytics_sync_service}
+/// The core orchestrator for the background worker.
+///
+/// This service reads the remote config to determine the active provider,
+/// instantiates the correct reporting client, and iterates through all
+/// [KpiCardId], [ChartCardId], and [RankedListCardId] enums.
+///
+/// For each ID, it fetches the corresponding data from the provider,
+/// transforms it into the appropriate [KpiCardData], [ChartCardData], or
+/// [RankedListCardData] model, and upserts it into the database using the
+/// generic repositories. This service encapsulates the entire ETL (Extract,
+/// Transform, Load) logic for analytics.
+///
+/// It delegates the construction of complex database queries to an
+/// [AnalyticsQueryBuilder] to keep this service clean and focused on
+/// orchestration.
+/// {@endtemplate}
+class AnalyticsSyncService {
+ /// {@macro analytics_sync_service}
+ AnalyticsSyncService({
+ required DataRepository remoteConfigRepository,
+ required DataRepository kpiCardRepository,
+ required DataRepository chartCardRepository,
+ required DataRepository rankedListCardRepository,
+ required DataRepository userRepository,
+ required DataRepository topicRepository,
+ required DataRepository reportRepository,
+ required DataRepository sourceRepository,
+ required DataRepository headlineRepository,
+ required DataRepository engagementRepository,
+ required DataRepository appReviewRepository,
+ required AnalyticsReportingClient? googleAnalyticsClient,
+ required AnalyticsReportingClient? mixpanelClient,
+ required AnalyticsMetricMapper analyticsMetricMapper,
+ required Logger log,
+ }) : _remoteConfigRepository = remoteConfigRepository,
+ _kpiCardRepository = kpiCardRepository,
+ _chartCardRepository = chartCardRepository,
+ _rankedListCardRepository = rankedListCardRepository,
+ _userRepository = userRepository,
+ _topicRepository = topicRepository,
+ _reportRepository = reportRepository,
+ _sourceRepository = sourceRepository,
+ _headlineRepository = headlineRepository,
+ _engagementRepository = engagementRepository,
+ _appReviewRepository = appReviewRepository,
+ _googleAnalyticsClient = googleAnalyticsClient,
+ _mixpanelClient = mixpanelClient,
+ _mapper = analyticsMetricMapper,
+ // The query builder is instantiated here as it is stateless.
+ _queryBuilder = AnalyticsQueryBuilder(),
+ _log = log;
+
+ final DataRepository _remoteConfigRepository;
+ final DataRepository _kpiCardRepository;
+ final DataRepository _chartCardRepository;
+ final DataRepository _rankedListCardRepository;
+ final DataRepository _userRepository;
+ final DataRepository _topicRepository;
+ final DataRepository _reportRepository;
+ final DataRepository _sourceRepository;
+ final DataRepository _headlineRepository;
+ final DataRepository _engagementRepository;
+ final DataRepository _appReviewRepository;
+ final AnalyticsReportingClient? _googleAnalyticsClient;
+ final AnalyticsReportingClient? _mixpanelClient;
+ final AnalyticsMetricMapper _mapper;
+ final AnalyticsQueryBuilder _queryBuilder;
+ final Logger _log;
+
+ /// Runs the entire analytics synchronization process.
+ Future run() async {
+ _log.info('Starting analytics sync process...');
+
+ try {
+ final remoteConfig = await _remoteConfigRepository.read(
+ id: kRemoteConfigId,
+ );
+ final analyticsConfig = remoteConfig.features.analytics;
+
+ if (!analyticsConfig.enabled) {
+ _log.info('Analytics is disabled in RemoteConfig. Skipping sync.');
+ return;
+ }
+
+ final client = _getClient(analyticsConfig.activeProvider);
+ if (client == null) {
+ _log.warning(
+ 'Analytics provider "${analyticsConfig.activeProvider.name}" is '
+ 'configured, but its client is not available or initialized. '
+ 'Skipping sync.',
+ );
+ return;
+ }
+
+ _log.info(
+ 'Syncing analytics data using provider: '
+ '"${analyticsConfig.activeProvider.name}".',
+ );
+
+ await _syncKpiCards(client);
+ await _syncChartCards(client);
+ await _syncRankedListCards(client);
+
+ _log.info('Analytics sync process completed successfully.');
+ } catch (e, s) {
+ _log.severe('Analytics sync process failed.', e, s);
+ }
+ }
+
+ /// Returns the appropriate analytics client based on the configured provider.
+ AnalyticsReportingClient? _getClient(AnalyticsProvider provider) {
+ switch (provider) {
+ case AnalyticsProvider.firebase:
+ return _googleAnalyticsClient;
+ case AnalyticsProvider.mixpanel:
+ return _mixpanelClient;
+ case AnalyticsProvider.demo:
+ return null;
+ }
+ }
+
+ /// Syncs all KPI cards defined in [KpiCardId].
+ Future _syncKpiCards(AnalyticsReportingClient client) async {
+ _log.info('Syncing KPI cards...');
+ for (final kpiId in KpiCardId.values) {
+ try {
+ final query = _mapper.getKpiQuery(kpiId);
+ if (query == null) {
+ _log.finer('No metric mapping for KPI ${kpiId.name}. Skipping.');
+ continue;
+ }
+
+ final isDatabaseQuery =
+ query is StandardMetricQuery &&
+ query.metric.startsWith('database:');
+
+ final isCalculatedQuery =
+ query is StandardMetricQuery &&
+ query.metric.startsWith('calculated:');
+
+ final timeFrames = {};
+ final now = DateTime.now();
+
+ for (final timeFrame in KpiTimeFrame.values) {
+ final days = _daysForKpiTimeFrame(timeFrame);
+ final startDate = now.subtract(Duration(days: days));
+ num value;
+
+ if (isDatabaseQuery) {
+ value = await _getDatabaseMetricTotal(
+ query,
+ startDate,
+ now,
+ );
+ } else if (isCalculatedQuery) {
+ value = await _getCalculatedMetricTotal(
+ query,
+ startDate,
+ now,
+ client,
+ );
+ } else {
+ value = await client.getMetricTotal(query, startDate, now);
+ }
+ num prevValue;
+ final prevPeriodStartDate = now.subtract(Duration(days: days * 2));
+ final prevPeriodEndDate = startDate;
+
+ if (isDatabaseQuery) {
+ prevValue = await _getDatabaseMetricTotal(
+ query,
+ prevPeriodStartDate,
+ prevPeriodEndDate,
+ );
+ } else if (isCalculatedQuery) {
+ prevValue = await _getCalculatedMetricTotal(
+ query,
+ prevPeriodStartDate,
+ prevPeriodEndDate,
+ client,
+ );
+ } else {
+ prevValue = await client.getMetricTotal(
+ query,
+ prevPeriodStartDate,
+ prevPeriodEndDate,
+ );
+ }
+
+ final trend = _calculateTrend(value, prevValue);
+ timeFrames[timeFrame] = KpiTimeFrameData(value: value, trend: trend);
+ }
+
+ final kpiCard = KpiCardData(
+ id: kpiId,
+ label: _formatLabel(kpiId.name),
+ timeFrames: timeFrames,
+ );
+
+ await _kpiCardRepository.update(
+ id: kpiId.name,
+ item: kpiCard,
+ );
+ _log.finer('Successfully synced KPI card: ${kpiId.name}');
+ } catch (e, s) {
+ _log.severe('Failed to sync KPI card: ${kpiId.name}', e, s);
+ }
+ }
+ }
+
+ Future _syncChartCards(AnalyticsReportingClient client) async {
+ _log.info('Syncing Chart cards...');
+ for (final chartId in ChartCardId.values) {
+ try {
+ final query = _mapper.getChartQuery(chartId);
+ if (query == null) {
+ _log.finer('No metric mapping for Chart ${chartId.name}. Skipping.');
+ continue;
+ }
+
+ final isDatabaseQuery =
+ query is StandardMetricQuery &&
+ query.metric.startsWith('database:');
+
+ final timeFrames = >{};
+ final now = DateTime.now();
+
+ for (final timeFrame in ChartTimeFrame.values) {
+ final days = _daysForChartTimeFrame(timeFrame);
+ final startDate = now.subtract(Duration(days: days));
+ List dataPoints;
+
+ if (isDatabaseQuery) {
+ dataPoints = await _getDatabaseTimeSeries(query, startDate, now);
+ } else {
+ dataPoints = await client.getTimeSeries(query, startDate, now);
+ }
+ timeFrames[timeFrame] = dataPoints;
+ }
+
+ final chartCard = ChartCardData(
+ id: chartId,
+ label: _formatLabel(chartId.name),
+ type: _chartTypeForId(chartId),
+ timeFrames: timeFrames,
+ );
+
+ await _chartCardRepository.update(
+ id: chartId.name,
+ item: chartCard,
+ );
+ _log.finer('Successfully synced Chart card: ${chartId.name}');
+ } catch (e, s) {
+ _log.severe('Failed to sync Chart card: ${chartId.name}', e, s);
+ }
+ }
+ }
+
+ Future _syncRankedListCards(AnalyticsReportingClient client) async {
+ _log.info('Syncing Ranked List cards...');
+ for (final rankedListId in RankedListCardId.values) {
+ try {
+ final query = _mapper.getRankedListQuery(rankedListId);
+ final isDatabaseQuery =
+ query is StandardMetricQuery &&
+ query.metric.startsWith('database:');
+
+ if (query == null || (!isDatabaseQuery && query is! RankedListQuery)) {
+ _log.finer(
+ 'No metric mapping for Ranked List ${rankedListId.name}. Skipping.',
+ );
+ continue;
+ }
+
+ final timeFrames = >{};
+ final now = DateTime.now();
+
+ for (final timeFrame in RankedListTimeFrame.values) {
+ final days = _daysForRankedListTimeFrame(timeFrame);
+ final startDate = now.subtract(Duration(days: days));
+ List items;
+
+ if (isDatabaseQuery) {
+ items = await _getDatabaseRankedList(
+ query,
+ startDate,
+ now,
+ );
+ } else {
+ items = await client.getRankedList(
+ query as RankedListQuery,
+ startDate,
+ now,
+ );
+ }
+ timeFrames[timeFrame] = items;
+ }
+
+ final rankedListCard = RankedListCardData(
+ id: rankedListId,
+ label: _formatLabel(rankedListId.name),
+ timeFrames: timeFrames,
+ );
+
+ await _rankedListCardRepository.update(
+ id: rankedListId.name,
+ item: rankedListCard,
+ );
+ _log.finer(
+ 'Successfully synced Ranked List card: ${rankedListId.name}',
+ );
+ } catch (e, s) {
+ _log.severe(
+ 'Failed to sync Ranked List card: ${rankedListId.name}',
+ e,
+ s,
+ );
+ }
+ }
+ }
+
+ /// Returns the number of days for a given Ranked List time frame.
+ int _daysForRankedListTimeFrame(RankedListTimeFrame timeFrame) {
+ switch (timeFrame) {
+ case RankedListTimeFrame.day:
+ return 1;
+ case RankedListTimeFrame.week:
+ return 7;
+ case RankedListTimeFrame.month:
+ return 30;
+ case RankedListTimeFrame.year:
+ return 365;
+ }
+ }
+
+ Future _getDatabaseMetricTotal(
+ StandardMetricQuery query,
+ DateTime startDate,
+ DateTime now,
+ ) async {
+ final filter = {
+ 'createdAt': {
+ r'$gte': startDate.toIso8601String(),
+ r'$lt': now.toIso8601String(),
+ },
+ };
+
+ switch (query.metric) {
+ case 'database:headlines:count':
+ return _headlineRepository.count(filter: filter);
+ case 'database:sources:count':
+ return _sourceRepository.count(filter: filter);
+ case 'database:topics:count':
+ return _topicRepository.count(filter: filter);
+ case 'database:sources:followers':
+ // This requires aggregation to sum the size of all follower arrays.
+ final pipeline = [
+ {
+ r'$project': {
+ 'followerCount': {r'$size': r'$followerIds'},
+ },
+ },
+ {
+ r'$group': {
+ '_id': null,
+ 'total': {r'$sum': r'$followerCount'},
+ },
+ },
+ ];
+ final result = await _sourceRepository.aggregate(pipeline: pipeline);
+ return result.firstOrNull?['total'] as num? ?? 0;
+ case 'database:topics:followers':
+ final pipeline = [
+ {
+ r'$project': {
+ 'followerCount': {r'$size': r'$followerIds'},
+ },
+ },
+ {
+ r'$group': {
+ '_id': null,
+ 'total': {r'$sum': r'$followerCount'},
+ },
+ },
+ ];
+ final result = await _topicRepository.aggregate(pipeline: pipeline);
+ return result.firstOrNull?['total'] as num? ?? 0;
+ case 'database:reports:pending':
+ return _reportRepository.count(
+ filter: {'status': ModerationStatus.pendingReview.name},
+ );
+ case 'database:reports:resolved':
+ return _reportRepository.count(
+ filter: {'status': ModerationStatus.resolved.name},
+ );
+ default:
+ _log.warning('Unsupported database metric total: ${query.metric}');
+ return 0;
+ }
+ }
+
+ Future> _getDatabaseTimeSeries(
+ StandardMetricQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ ) async {
+ final pipeline = _queryBuilder.buildPipelineForMetric(
+ query,
+ startDate,
+ endDate,
+ );
+
+ if (pipeline == null) {
+ _log.warning('No pipeline for database time series: ${query.metric}');
+ return [];
+ }
+
+ // Determine the correct repository based on the metric name.
+ final repo = _getRepositoryForMetric(query.metric);
+ if (repo == null) {
+ _log.severe('No repository found for metric: ${query.metric}');
+ return [];
+ }
+
+ final results = await repo.aggregate(pipeline: pipeline);
+ return results.map((e) {
+ final label = e['label']?.toString() ?? 'Unknown';
+ final value = (e['value'] as num?) ?? 0;
+
+ final formattedLabel = label
+ .split(' ')
+ .map((word) {
+ if (word.isEmpty) return '';
+ return '${word[0].toUpperCase()}${word.substring(1)}';
+ })
+ .join(' ');
+ return DataPoint(label: formattedLabel, value: value);
+ }).toList();
+ }
+
+ Future> _getDatabaseRankedList(
+ StandardMetricQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ ) async {
+ final pipeline = _queryBuilder.buildPipelineForMetric(
+ query,
+ startDate,
+ endDate,
+ );
+
+ if (pipeline == null) {
+ _log.warning('No pipeline for database ranked list: ${query.metric}');
+ return [];
+ }
+
+ final repo = _getRepositoryForMetric(query.metric);
+ if (repo == null) {
+ _log.severe('No repository found for metric: ${query.metric}');
+ return [];
+ }
+
+ final results = await repo.aggregate(pipeline: pipeline);
+ return results
+ .map((e) {
+ final entityId = e['entityId'] as String?;
+ final displayTitle = e['displayTitle'] as String?;
+ final metricValue = e['metricValue'] as num?;
+
+ if (entityId == null || displayTitle == null || metricValue == null) {
+ _log.warning('Skipping ranked list item with missing data: $e');
+ return null;
+ }
+
+ return RankedListItem(
+ entityId: entityId,
+ displayTitle: displayTitle,
+ metricValue: metricValue,
+ );
+ })
+ .whereType()
+ .toList();
+ }
+
+ DataRepository? _getRepositoryForMetric(String metric) {
+ final parts = metric.split(':');
+ if (parts.length < 2 || parts[0] != 'database') {
+ _log.warning('Invalid or non-database metric format: $metric');
+ return null;
+ }
+ final collectionName = parts[1];
+
+ // A map for reliable, non-ambiguous repository lookup.
+ final repositoryMap = >{
+ 'users': _userRepository,
+ 'reports': _reportRepository,
+ 'engagements': _engagementRepository,
+ 'app_reviews': _appReviewRepository,
+ 'sources': _sourceRepository,
+ 'topics': _topicRepository,
+ 'headlines': _headlineRepository,
+ };
+
+ final repo = repositoryMap[collectionName];
+ if (repo == null) {
+ _log.severe(
+ 'No repository found for collection: "$collectionName" in metric "$metric".',
+ );
+ }
+ return repo;
+ }
+
+ /// Calculates a metric that depends on other metrics.
+ Future _getCalculatedMetricTotal(
+ StandardMetricQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ AnalyticsReportingClient client,
+ ) async {
+ switch (query.metric) {
+ case 'calculated:engagementRate':
+ // Engagement Rate = (Total Reactions / Total Views) * 100
+ const totalReactionsQuery = EventCountQuery(
+ event: AnalyticsEvent.reactionCreated,
+ );
+ const totalViewsQuery = EventCountQuery(
+ event: AnalyticsEvent.contentViewed,
+ );
+
+ final totalReactions = await client.getMetricTotal(
+ totalReactionsQuery,
+ startDate,
+ endDate,
+ );
+ final totalViews = await client.getMetricTotal(
+ totalViewsQuery,
+ startDate,
+ endDate,
+ );
+
+ if (totalViews == 0) return 0;
+ return (totalReactions / totalViews) * 100;
+ default:
+ _log.warning('Unsupported calculated metric: ${query.metric}');
+ return 0;
+ }
+ }
+
+ int _daysForKpiTimeFrame(KpiTimeFrame timeFrame) {
+ switch (timeFrame) {
+ case KpiTimeFrame.day:
+ return 1;
+ case KpiTimeFrame.week:
+ return 7;
+ case KpiTimeFrame.month:
+ return 30;
+ case KpiTimeFrame.year:
+ return 365;
+ }
+ }
+
+ /// Returns the number of days for a given Chart time frame.
+ int _daysForChartTimeFrame(ChartTimeFrame timeFrame) {
+ switch (timeFrame) {
+ case ChartTimeFrame.week:
+ return 7;
+ case ChartTimeFrame.month:
+ return 30;
+ case ChartTimeFrame.year:
+ return 365;
+ }
+ }
+
+ String _calculateTrend(num currentValue, num previousValue) {
+ if (previousValue == 0) {
+ return currentValue > 0 ? '+100%' : '0%';
+ }
+ final percentageChange =
+ ((currentValue - previousValue) / previousValue) * 100;
+ return '${percentageChange.isNegative ? '' : '+'}'
+ '${percentageChange.toStringAsFixed(1)}%';
+ }
+
+ String _formatLabel(String idName) => idName
+ .replaceAll(RegExp('([A-Z])'), r' $1')
+ .trim()
+ .split(' ')
+ .map((w) => '${w[0].toUpperCase()}${w.substring(1)}')
+ .join(' ');
+
+ ChartType _chartTypeForId(ChartCardId id) =>
+ id.name.contains('distribution') || id.name.contains('by_')
+ ? ChartType.bar
+ : ChartType.line;
+}
diff --git a/lib/src/services/analytics/google_analytics_data_client.dart b/lib/src/services/analytics/google_analytics_data_client.dart
new file mode 100644
index 00000000..0a621125
--- /dev/null
+++ b/lib/src/services/analytics/google_analytics_data_client.dart
@@ -0,0 +1,246 @@
+import 'package:core/core.dart';
+import 'package:data_repository/data_repository.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/models/models.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/services/analytics/analytics.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_authenticator.dart';
+import 'package:http_client/http_client.dart';
+import 'package:intl/intl.dart';
+import 'package:logging/logging.dart';
+
+/// {@template google_analytics_data_client}
+/// A concrete implementation of [AnalyticsReportingClient] for fetching data
+/// from the Google Analytics Data API (v1beta).
+///
+/// This client is responsible for constructing and sending `runReport` requests
+/// to the GA4 property associated with the Firebase project.
+/// {@endtemplate}
+class GoogleAnalyticsDataClient implements AnalyticsReportingClient {
+ /// {@macro google_analytics_data_client}
+ GoogleAnalyticsDataClient({
+ required String propertyId,
+ required IFirebaseAuthenticator firebaseAuthenticator,
+ required Logger log,
+ required DataRepository headlineRepository,
+ HttpClient? httpClient,
+ }) : _propertyId = propertyId,
+ _log = log,
+ _headlineRepository = headlineRepository,
+ _httpClient =
+ httpClient ??
+ HttpClient(
+ baseUrl: 'https://analyticsdata.googleapis.com/v1beta',
+ tokenProvider: firebaseAuthenticator.getAccessToken,
+ logger: log,
+ );
+
+ final String _propertyId;
+ final HttpClient _httpClient;
+ final Logger _log;
+ final DataRepository _headlineRepository;
+
+ String _getMetricName(MetricQuery query) {
+ return switch (query) {
+ EventCountQuery() => 'eventCount',
+ StandardMetricQuery(metric: final m) => m,
+ };
+ }
+
+ @override
+ Future> getTimeSeries(
+ MetricQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ ) async {
+ final metricName = _getMetricName(query);
+ if (metricName.startsWith('database:')) {
+ throw ArgumentError.value(
+ query,
+ 'query',
+ 'Database queries cannot be handled by GoogleAnalyticsDataClient.',
+ );
+ }
+
+ _log.info(
+ 'Fetching time series for metric "$metricName" from Google Analytics.',
+ );
+
+ final request = RunReportRequest(
+ dateRanges: [
+ GARequestDateRange(
+ startDate: DateFormat('y-MM-dd').format(startDate),
+ endDate: DateFormat('y-MM-dd').format(endDate),
+ ),
+ ],
+ dimensions: const [
+ GARequestDimension(name: 'date'),
+ ],
+ metrics: [
+ GARequestMetric(name: metricName),
+ ],
+ dimensionFilter: query is EventCountQuery
+ ? GARequestFilterExpression(
+ filter: GARequestFilter(
+ fieldName: 'eventName',
+ stringFilter: GARequestStringFilter(value: query.event.name),
+ ),
+ )
+ : null,
+ );
+
+ final response = await _runReport(request.toJson());
+
+ final rows = response.rows;
+ if (rows == null || rows.isEmpty) {
+ _log.finer('No time series data returned from Google Analytics.');
+ return [];
+ }
+
+ return rows
+ .map((row) {
+ final dateStr = row.dimensionValues.firstOrNull?.value;
+ final valueStr = row.metricValues.firstOrNull?.value;
+ if (dateStr == null || valueStr == null) return null;
+
+ return DataPoint(
+ timestamp: DateTime.parse(dateStr),
+ value: num.tryParse(valueStr) ?? 0.0,
+ );
+ })
+ .whereType()
+ .toList();
+ }
+
+ @override
+ Future getMetricTotal(
+ MetricQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ ) async {
+ final metricName = _getMetricName(query);
+ if (metricName.startsWith('database:')) {
+ throw ArgumentError.value(
+ query,
+ 'query',
+ 'Database queries cannot be handled by GoogleAnalyticsDataClient.',
+ );
+ }
+
+ _log.info('Fetching total for metric "$metricName" from Google Analytics.');
+ final request = RunReportRequest(
+ dateRanges: [
+ GARequestDateRange(
+ startDate: DateFormat('y-MM-dd').format(startDate),
+ endDate: DateFormat('y-MM-dd').format(endDate),
+ ),
+ ],
+ metrics: [
+ GARequestMetric(name: metricName),
+ ],
+ dimensionFilter: query is EventCountQuery
+ ? GARequestFilterExpression(
+ filter: GARequestFilter(
+ fieldName: 'eventName',
+ stringFilter: GARequestStringFilter(value: query.event.name),
+ ),
+ )
+ : null,
+ );
+
+ final response = await _runReport(request.toJson());
+
+ final rows = response.rows;
+ if (rows == null || rows.isEmpty) {
+ _log.finer('No metric total data returned from Google Analytics.');
+ return 0;
+ }
+
+ final valueStr = rows.firstOrNull?.metricValues.firstOrNull?.value;
+ return num.tryParse(valueStr ?? '0') ?? 0.0;
+ }
+
+ @override
+ Future> getRankedList(
+ RankedListQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ ) async {
+ const metricName = 'eventCount'; // Ranked lists are always event counts
+ final dimensionName = query.dimension;
+
+ _log.info(
+ 'Fetching ranked list for dimension "$dimensionName" by metric '
+ '"$metricName" from Google Analytics.',
+ );
+ final request = RunReportRequest(
+ dateRanges: [
+ GARequestDateRange(
+ startDate: DateFormat('y-MM-dd').format(startDate),
+ endDate: DateFormat('y-MM-dd').format(endDate),
+ ),
+ ],
+ dimensions: [
+ GARequestDimension(name: 'customEvent:$dimensionName'),
+ ],
+ metrics: const [
+ GARequestMetric(name: metricName),
+ ],
+ limit: query.limit,
+ dimensionFilter: GARequestFilterExpression(
+ filter: GARequestFilter(
+ fieldName: 'eventName',
+ stringFilter: GARequestStringFilter(value: query.event.name),
+ ),
+ ),
+ );
+
+ final response = await _runReport(request.toJson());
+
+ final rows = response.rows;
+ if (rows == null || rows.isEmpty) {
+ _log.finer('No ranked list data returned from Google Analytics.');
+ return [];
+ }
+
+ final rawItems = [];
+ for (final row in rows) {
+ final entityId = row.dimensionValues.firstOrNull?.value;
+ final metricValueStr = row.metricValues.firstOrNull?.value;
+ if (entityId == null || metricValueStr == null) continue;
+
+ final metricValue = num.tryParse(metricValueStr) ?? 0;
+ rawItems.add(
+ RankedListItem(
+ entityId: entityId,
+ displayTitle: '',
+ metricValue: metricValue,
+ ),
+ );
+ }
+
+ final headlineIds = rawItems.map((item) => item.entityId).toList();
+ final paginatedHeadlines = await _headlineRepository.readAll(
+ filter: {
+ '_id': {r'$in': headlineIds},
+ },
+ );
+ final headlineMap = {
+ for (final h in paginatedHeadlines.items) h.id: h.title,
+ };
+
+ return rawItems
+ .map(
+ (item) => item.copyWith(
+ displayTitle: headlineMap[item.entityId] ?? 'Unknown Headline',
+ ),
+ )
+ .toList();
+ }
+
+ Future _runReport(Map requestBody) async {
+ final response = await _httpClient.post>(
+ '/properties/$_propertyId:runReport',
+ data: requestBody,
+ );
+ return RunReportResponse.fromJson(response);
+ }
+}
diff --git a/lib/src/services/analytics/mixpanel_data_client.dart b/lib/src/services/analytics/mixpanel_data_client.dart
new file mode 100644
index 00000000..31248ca1
--- /dev/null
+++ b/lib/src/services/analytics/mixpanel_data_client.dart
@@ -0,0 +1,244 @@
+import 'dart:convert';
+
+import 'package:core/core.dart';
+import 'package:data_repository/data_repository.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/models/models.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/services/analytics/analytics.dart';
+import 'package:http_client/http_client.dart';
+import 'package:intl/intl.dart';
+import 'package:logging/logging.dart';
+
+/// {@template mixpanel_data_client}
+/// A concrete implementation of [AnalyticsReportingClient] for fetching data
+/// from the Mixpanel API.
+/// {@endtemplate}
+class MixpanelDataClient implements AnalyticsReportingClient {
+ /// {@macro mixpanel_data_client}
+ MixpanelDataClient({
+ required String projectId,
+ required String serviceAccountUsername,
+ required String serviceAccountSecret,
+ required Logger log,
+ required DataRepository headlineRepository,
+ HttpClient? httpClient,
+ }) : _projectId = projectId,
+ _log = log,
+ _headlineRepository = headlineRepository,
+ _httpClient =
+ httpClient ??
+ _createDefaultHttpClient(
+ serviceAccountUsername,
+ serviceAccountSecret,
+ log,
+ );
+
+ final String _projectId;
+ late final HttpClient _httpClient;
+ final Logger _log;
+ final DataRepository _headlineRepository;
+
+ // A private static method to create the default HttpClient.
+ // This keeps the constructor clean and allows for easy test injection.
+ static HttpClient _createDefaultHttpClient(
+ String username,
+ String secret,
+ Logger log,
+ ) {
+ final credentials = base64Encode('$username:$secret'.codeUnits);
+ return HttpClient(
+ baseUrl: 'https://mixpanel.com/api/2.0',
+ tokenProvider: () async => null,
+ interceptors: [
+ InterceptorsWrapper(
+ onRequest: (options, handler) {
+ options.headers['Authorization'] = 'Basic $credentials';
+ return handler.next(options);
+ },
+ ),
+ ],
+ logger: log,
+ );
+ }
+
+ String _getMetricName(MetricQuery query) {
+ return switch (query) {
+ EventCountQuery(event: final e) => e.name,
+ StandardMetricQuery(metric: final m) => m,
+ };
+ }
+
+ @override
+ Future> getTimeSeries(
+ MetricQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ ) async {
+ var metricName = _getMetricName(query);
+ if (metricName.startsWith('database:')) {
+ throw ArgumentError.value(
+ query,
+ 'query',
+ 'Database queries cannot be handled by MixpanelDataClient.',
+ );
+ }
+ if (metricName == 'activeUsers') {
+ // Mixpanel uses a special name for active users.
+ metricName = r'$active';
+ }
+
+ _log.info('Fetching time series for metric "$metricName" from Mixpanel.');
+
+ final request = MixpanelSegmentationRequest(
+ projectId: _projectId,
+ event: metricName,
+ fromDate: DateFormat('yyyy-MM-dd').format(startDate),
+ toDate: DateFormat('yyyy-MM-dd').format(endDate),
+ unit: MixpanelTimeUnit.day,
+ );
+
+ final response = await _httpClient.get>(
+ '/segmentation',
+ queryParameters: request.toJson(),
+ );
+
+ final segmentationData =
+ MixpanelResponse.fromJson(
+ response,
+ (json) =>
+ MixpanelSegmentationData.fromJson(json as Map),
+ ).data;
+
+ final dataPoints = [];
+ final series = segmentationData.series;
+ final values = segmentationData.values[metricName] ?? [];
+
+ for (var i = 0; i < series.length; i++) {
+ dataPoints.add(
+ DataPoint(
+ timestamp: DateTime.parse(series[i]),
+ value: values.isNotEmpty ? values[i] : 0,
+ ),
+ );
+ }
+ return dataPoints;
+ }
+
+ @override
+ Future getMetricTotal(
+ MetricQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ ) async {
+ var metricName = _getMetricName(query);
+ if (metricName.startsWith('database:')) {
+ throw ArgumentError.value(
+ query,
+ 'query',
+ 'Database queries cannot be handled by MixpanelDataClient.',
+ );
+ }
+ if (metricName == 'activeUsers') {
+ metricName = r'$active';
+ }
+
+ _log.info('Fetching total for metric "$metricName" from Mixpanel.');
+
+ // To get a single total, we call the segmentation endpoint without the 'unit'
+ // parameter. The MixpanelSegmentationRequest model supports this by having
+ // a nullable 'unit'.
+ final request = MixpanelSegmentationRequest(
+ projectId: _projectId,
+ event: metricName,
+ fromDate: DateFormat('yyyy-MM-dd').format(startDate),
+ toDate: DateFormat('yyyy-MM-dd').format(endDate),
+ );
+
+ final response = await _httpClient.get>(
+ '/segmentation',
+ queryParameters: request.toJson(),
+ );
+
+ final segmentationData =
+ MixpanelResponse.fromJson(
+ response,
+ (json) =>
+ MixpanelSegmentationData.fromJson(json as Map),
+ ).data;
+
+ // The response for a total value has a single entry in the 'values' map.
+ if (segmentationData.values.values.isEmpty ||
+ segmentationData.values.values.first.isEmpty) {
+ return 0;
+ }
+ // Sum all values in the first (and typically only) list of values.
+ return segmentationData.values.values.first.fold(
+ 0,
+ (previousValue, element) => previousValue + (element as num),
+ );
+ }
+
+ @override
+ Future> getRankedList(
+ RankedListQuery query,
+ DateTime startDate,
+ DateTime endDate,
+ ) async {
+ final metricName = query.event.name;
+ final dimensionName = query.dimension;
+ _log.info(
+ 'Fetching ranked list for dimension "$dimensionName" by metric '
+ '"$metricName" from Mixpanel.',
+ );
+
+ final request = MixpanelTopEventsRequest(
+ projectId: _projectId,
+ event: metricName,
+ name: dimensionName,
+ fromDate: DateFormat('yyyy-MM-dd').format(startDate),
+ toDate: DateFormat('yyyy-MM-dd').format(endDate),
+ limit: query.limit,
+ );
+
+ final response = await _httpClient.get>(
+ '/events/properties/top',
+ queryParameters: request.toJson(),
+ );
+
+ final rawItems = [];
+ response.forEach((key, value) {
+ if (value is! Map || !value.containsKey('count')) return;
+ final count = value['count'];
+ if (count is! num) return;
+
+ rawItems.add(
+ RankedListItem(
+ entityId: key,
+ displayTitle: '',
+ metricValue: count,
+ ),
+ );
+ });
+
+ rawItems.sort((a, b) => b.metricValue.compareTo(a.metricValue));
+
+ final headlineIds = rawItems.map((item) => item.entityId).toList();
+ if (headlineIds.isEmpty) return [];
+
+ final paginatedHeadlines = await _headlineRepository.readAll(
+ filter: {
+ '_id': {r'$in': headlineIds},
+ },
+ );
+ final headlineMap = {
+ for (final h in paginatedHeadlines.items) h.id: h.title,
+ };
+
+ return rawItems
+ .map(
+ (item) => item.copyWith(
+ displayTitle: headlineMap[item.entityId] ?? 'Unknown Headline',
+ ),
+ )
+ .toList();
+ }
+}
diff --git a/lib/src/services/dashboard_summary_service.dart b/lib/src/services/dashboard_summary_service.dart
deleted file mode 100644
index 6d2ab76c..00000000
--- a/lib/src/services/dashboard_summary_service.dart
+++ /dev/null
@@ -1,52 +0,0 @@
-import 'package:core/core.dart';
-import 'package:data_repository/data_repository.dart';
-
-/// {@template dashboard_summary_service}
-/// A service responsible for calculating the dashboard summary data on demand.
-///
-/// This service aggregates data from various repositories to provide a
-/// real-time overview of key metrics in the system.
-/// {@endtemplate}
-class DashboardSummaryService {
- /// {@macro dashboard_summary_service}
- const DashboardSummaryService({
- required DataRepository headlineRepository,
- required DataRepository topicRepository,
- required DataRepository sourceRepository,
- }) : _headlineRepository = headlineRepository,
- _topicRepository = topicRepository,
- _sourceRepository = sourceRepository;
-
- final DataRepository _headlineRepository;
- final DataRepository _topicRepository;
- final DataRepository _sourceRepository;
-
- /// Calculates and returns the current dashboard summary.
- ///
- /// This method fetches the counts of all items from the required
- /// repositories and constructs a [DashboardSummary] object.
- Future getSummary() async {
- // Define a filter to count only documents with an 'active' status.
- // Using the enum's `name` property ensures type safety and consistency.
- final activeFilter = {'status': ContentStatus.active.name};
-
- // Use Future.wait to fetch all counts in parallel for efficiency.
- final results = await Future.wait([
- _headlineRepository.count(filter: activeFilter),
- _topicRepository.count(filter: activeFilter),
- _sourceRepository.count(filter: activeFilter),
- ]);
-
- // The results are integers.
- final headlineCount = results[0];
- final topicCount = results[1];
- final sourceCount = results[2];
-
- return DashboardSummary(
- id: 'dashboard_summary',
- headlineCount: headlineCount,
- topicCount: topicCount,
- sourceCount: sourceCount,
- );
- }
-}
diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart
index 7e77eff6..31d88d44 100644
--- a/lib/src/services/database_seeding_service.dart
+++ b/lib/src/services/database_seeding_service.dart
@@ -111,40 +111,49 @@ class DatabaseSeedingService {
final defaultRemoteConfigId = remoteConfigsFixturesData.first.id;
final objectId = ObjectId.fromHexString(defaultRemoteConfigId);
- final existingConfig = await remoteConfigCollection.findOne(
- where.id(objectId),
- );
-
- if (existingConfig == null) {
- _log.info('No existing RemoteConfig found. Creating initial config.');
- // Take the default from fixtures
- final initialConfig = remoteConfigsFixturesData.first;
-
- // Ensure primaryAdPlatform is not 'demo' for initial setup
- // since its not intended for any use outside the mobile client.
- final productionReadyAdConfig = initialConfig.features.ads.copyWith(
- primaryAdPlatform: AdPlatformType.admob,
- );
-
- final productionReadyConfig = initialConfig.copyWith(
- createdAt: DateTime.now(),
- updatedAt: DateTime.now(),
- features: initialConfig.features.copyWith(
- ads: productionReadyAdConfig,
+ // Start with the default configuration from the fixtures data.
+ final initialConfig = remoteConfigsFixturesData.first;
+ var featuresConfig = initialConfig.features;
+
+ // --- Generic Sanitization for Production/Staging Environments ---
+ // This block ensures that any configuration option with a 'demo'
+ // variant is reset to a sensible, non-demo default. This prevents
+ // 'demo' options from being the default in a real environment.
+
+ // Sanitize Ad Platform
+ if (featuresConfig.ads.primaryAdPlatform == AdPlatformType.demo) {
+ featuresConfig = featuresConfig.copyWith(
+ ads: featuresConfig.ads.copyWith(
+ primaryAdPlatform: AdPlatformType.admob, // Default to AdMob
),
);
+ }
- await remoteConfigCollection.insertOne({
- '_id': objectId,
- ...productionReadyConfig.toJson()..remove('id'),
- });
- _log.info('Initial RemoteConfig created successfully.');
- } else {
- _log.info(
- 'RemoteConfig already exists. Skipping creation. '
- 'Schema updates are handled by DatabaseMigrationService.',
+ // Sanitize Analytics Provider
+ if (featuresConfig.analytics.activeProvider == AnalyticsProvider.demo) {
+ featuresConfig = featuresConfig.copyWith(
+ analytics: featuresConfig.analytics.copyWith(
+ activeProvider: AnalyticsProvider.firebase, // Default to Firebase
+ ),
);
}
+
+ final productionReadyConfig = initialConfig.copyWith(
+ features: featuresConfig,
+ // Ensure timestamps are set for the initial creation.
+ createdAt: DateTime.now(),
+ updatedAt: DateTime.now(),
+ );
+
+ // Use `updateOne` with `$setOnInsert` to create the document only if it
+ // does not exist. This prevents overwriting an admin's custom changes
+ // on subsequent server restarts.
+ await remoteConfigCollection.updateOne(
+ where.id(objectId),
+ {r'$setOnInsert': productionReadyConfig.toJson()..remove('id')},
+ upsert: true,
+ );
+ _log.info('Ensured RemoteConfig document exists and is sanitized.');
} on Exception catch (e, s) {
_log.severe('Failed to seed RemoteConfig.', e, s);
rethrow;
@@ -158,27 +167,6 @@ class DatabaseSeedingService {
Future _ensureIndexes() async {
_log.info('Ensuring database indexes exist...');
try {
- /// Text index for searching headlines by title.
- /// This index supports efficient full-text search queries on the 'title' field
- /// of headline documents, crucial for the main search functionality.
- await _db
- .collection('headlines')
- .createIndex(keys: {'title': 'text'}, name: 'headlines_text_index');
-
- /// Text index for searching topics by name.
- /// This index enables efficient full-text search on the 'name' field of
- /// topic documents, used for searching topics.
- await _db
- .collection('topics')
- .createIndex(keys: {'name': 'text'}, name: 'topics_text_index');
-
- /// Text index for searching sources by name.
- /// This index facilitates efficient full-text search on the 'name' field of
- /// source documents, used for searching sources.
- await _db
- .collection('sources')
- .createIndex(keys: {'name': 'text'}, name: 'sources_text_index');
-
/// Index for searching countries by name.
/// This index supports efficient queries and sorting on the 'name' field
/// of country documents, particularly for direct country searches.
@@ -326,6 +314,156 @@ class DatabaseSeedingService {
});
_log.info('Ensured indexes for "app_reviews".');
+ // Indexes for the users collection
+ await _db.runCommand({
+ 'createIndexes': 'users',
+ 'indexes': [
+ {
+ // For `users` collection aggregations (e.g., role distribution).
+ 'key': {'appRole': 1},
+ 'name': 'analytics_user_role_index',
+ },
+ ],
+ });
+ _log.info('Ensured analytics indexes for "users".');
+
+ // Indexes for the reports collection
+ await _db.runCommand({
+ 'createIndexes': 'reports',
+ 'indexes': [
+ {
+ // Optimizes fetching all reports submitted by a specific user.
+ 'key': {'reporterUserId': 1},
+ 'name': 'reporterUserId_index',
+ },
+ {
+ // For `reports` collection aggregations (e.g., by reason).
+ 'key': {'createdAt': 1, 'reason': 1},
+ 'name': 'analytics_report_reason_index',
+ },
+ {
+ // For `reports` collection aggregations (e.g., resolution time).
+ 'key': {'status': 1, 'updatedAt': 1},
+ 'name': 'analytics_report_resolution_index',
+ },
+ ],
+ });
+ _log.info('Ensured analytics indexes for "reports".');
+
+ // Indexes for the engagements collection
+ await _db.runCommand({
+ 'createIndexes': 'engagements',
+ 'indexes': [
+ {
+ // Optimizes fetching all engagements for a specific user.
+ 'key': {'userId': 1},
+ 'name': 'userId_index',
+ },
+ {
+ // Optimizes fetching all engagements for a specific entity.
+ 'key': {'entityId': 1, 'entityType': 1},
+ 'name': 'entity_index',
+ },
+ {
+ // For `engagements` collection aggregations (e.g., reactions by type).
+ 'key': {'createdAt': 1, 'reaction.reactionType': 1},
+ 'name': 'analytics_engagement_reaction_type_index',
+ },
+ ],
+ });
+ _log.info('Ensured analytics indexes for "engagements".');
+
+ // Indexes for the headlines collection
+ await _db.runCommand({
+ 'createIndexes': 'headlines',
+ 'indexes': [
+ {
+ 'key': {'title': 'text'},
+ 'name': 'headlines_text_index',
+ },
+ {
+ 'key': {'createdAt': 1, 'topic.name': 1},
+ 'name': 'analytics_headline_topic_index',
+ },
+ {
+ 'key': {'createdAt': 1, 'source.name': 1},
+ 'name': 'analytics_headline_source_index',
+ },
+ {
+ 'key': {'createdAt': 1, 'isBreaking': 1},
+ 'name': 'analytics_headline_breaking_index',
+ },
+ ],
+ });
+ _log.info('Ensured analytics indexes for "headlines".');
+
+ // Indexes for the sources collection
+ await _db.runCommand({
+ 'createIndexes': 'sources',
+ 'indexes': [
+ {
+ 'key': {'name': 'text'},
+ 'name': 'sources_text_index',
+ },
+ {
+ 'key': {'followerIds': 1},
+ 'name': 'analytics_source_followers_index',
+ },
+ ],
+ });
+
+ // Indexes for the topics collection
+ await _db.runCommand({
+ 'createIndexes': 'topics',
+ 'indexes': [
+ {
+ 'key': {'name': 'text'},
+ 'name': 'topics_text_index',
+ },
+ {
+ 'key': {'followerIds': 1},
+ 'name': 'analytics_topic_followers_index',
+ },
+ ],
+ });
+
+ // Indexes for the analytics card data collections.
+ // Using runCommand to ensure no default 'unique' field is added, which
+ // is invalid for an _id index. This also ensures the collections exist
+ // on startup.
+ await _db.runCommand({
+ 'createIndexes': 'kpi_card_data',
+ 'indexes': [
+ {
+ 'key': {'_id': 1},
+ 'name': 'kpi_card_data_id_index',
+ },
+ ],
+ });
+ _log.info('Ensured indexes for "kpi_card_data".');
+
+ await _db.runCommand({
+ 'createIndexes': 'chart_card_data',
+ 'indexes': [
+ {
+ 'key': {'_id': 1},
+ 'name': 'chart_card_data_id_index',
+ },
+ ],
+ });
+ _log.info('Ensured indexes for "chart_card_data".');
+
+ await _db.runCommand({
+ 'createIndexes': 'ranked_list_card_data',
+ 'indexes': [
+ {
+ 'key': {'_id': 1},
+ 'name': 'ranked_list_card_data_id_index',
+ },
+ ],
+ });
+ _log.info('Ensured indexes for "ranked_list_card_data".');
+
_log.info('Database indexes are set up correctly.');
} on Exception catch (e, s) {
_log.severe('Failed to create database indexes.', e, s);
diff --git a/pubspec.lock b/pubspec.lock
index 837da0ed..9f49e599 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -181,8 +181,8 @@ packages:
dependency: "direct main"
description:
path: "."
- ref: "v1.4.0"
- resolved-ref: d26d4bfecf4c93cdcdda91dbc06c0a71f272b3f0
+ ref: a8a1714fe24ad0944fde8e38e6ed3af831568e75
+ resolved-ref: a8a1714fe24ad0944fde8e38e6ed3af831568e75
url: "https://github.com/flutter-news-app-full-source-code/core.git"
source: git
version: "1.4.0"
@@ -426,7 +426,7 @@ packages:
source: hosted
version: "4.1.2"
intl:
- dependency: transitive
+ dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
diff --git a/pubspec.yaml b/pubspec.yaml
index c33f6859..d98a8d25 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -45,6 +45,7 @@ dependencies:
git:
url: https://github.com/flutter-news-app-full-source-code/http-client.git
ref: v1.0.1
+ intl: ^0.20.2
json_annotation: ^4.9.0
logging: ^1.3.0
meta: ^1.16.0
@@ -53,7 +54,7 @@ dependencies:
dev_dependencies:
build_runner: ^2.10.4
- json_serializable: ^6.11.3
+ json_serializable: ^6.10.0
mocktail: ^1.0.3
test: ^1.25.5
very_good_analysis: ^9.0.0
@@ -62,7 +63,7 @@ dependency_overrides:
core:
git:
url: https://github.com/flutter-news-app-full-source-code/core.git
- ref: v1.4.0
+ ref: a8a1714fe24ad0944fde8e38e6ed3af831568e75
data_mongodb:
git:
url: https://github.com/flutter-news-app-full-source-code/data-mongodb.git
diff --git a/routes/_middleware.dart b/routes/_middleware.dart
index 9afd5b51..4e210a15 100644
--- a/routes/_middleware.dart
+++ b/routes/_middleware.dart
@@ -11,7 +11,6 @@ import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_
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_query_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/firebase_authenticator.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart';
@@ -154,6 +153,21 @@ Handler middleware(Handler handler) {
(_) => deps.appReviewRepository,
),
)
+ .use(
+ provider>(
+ (_) => deps.kpiCardDataRepository,
+ ),
+ )
+ .use(
+ provider>(
+ (_) => deps.chartCardDataRepository,
+ ),
+ )
+ .use(
+ provider>(
+ (_) => deps.rankedListCardDataRepository,
+ ),
+ )
.use(
provider(
(_) => deps.pushNotificationService,
@@ -177,11 +191,6 @@ Handler middleware(Handler handler) {
),
)
.use(provider((_) => deps.authService))
- .use(
- provider(
- (_) => deps.dashboardSummaryService,
- ),
- )
.use(provider((_) => deps.permissionService))
.use(
provider(
diff --git a/test/src/models/analytics/analytics_query_test.dart b/test/src/models/analytics/analytics_query_test.dart
new file mode 100644
index 00000000..dc72845d
--- /dev/null
+++ b/test/src/models/analytics/analytics_query_test.dart
@@ -0,0 +1,41 @@
+import 'package:core/core.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/models/analytics/analytics.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('AnalyticsQuery', () {
+ group('EventCountQuery', () {
+ test('supports value equality', () {
+ const query1 = EventCountQuery(event: AnalyticsEvent.contentViewed);
+ const query2 = EventCountQuery(event: AnalyticsEvent.contentViewed);
+ const query3 = EventCountQuery(event: AnalyticsEvent.userLogin);
+ expect(query1, equals(query2));
+ expect(query1, isNot(equals(query3)));
+ });
+ });
+
+ group('StandardMetricQuery', () {
+ test('supports value equality', () {
+ const query1 = StandardMetricQuery(metric: 'activeUsers');
+ const query2 = StandardMetricQuery(metric: 'activeUsers');
+ const query3 = StandardMetricQuery(metric: 'totalUsers');
+ expect(query1, equals(query2));
+ expect(query1, isNot(equals(query3)));
+ });
+ });
+
+ group('RankedListQuery', () {
+ test('supports value equality', () {
+ const query1 = RankedListQuery(
+ event: AnalyticsEvent.contentViewed,
+ dimension: 'contentId',
+ );
+ const query2 = RankedListQuery(
+ event: AnalyticsEvent.contentViewed,
+ dimension: 'contentId',
+ );
+ expect(query1, equals(query2));
+ });
+ });
+ });
+}
diff --git a/test/src/models/analytics/google_analytics_request_test.dart b/test/src/models/analytics/google_analytics_request_test.dart
new file mode 100644
index 00000000..dfa1fb42
--- /dev/null
+++ b/test/src/models/analytics/google_analytics_request_test.dart
@@ -0,0 +1,59 @@
+import 'package:flutter_news_app_api_server_full_source_code/src/models/analytics/analytics.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('Google Analytics Request Models', () {
+ group('RunReportRequest', () {
+ test('toJson produces correct map structure', () {
+ const request = RunReportRequest(
+ dateRanges: [
+ GARequestDateRange(startDate: '2024-01-01', endDate: '2024-01-31'),
+ ],
+ dimensions: [GARequestDimension(name: 'date')],
+ metrics: [GARequestMetric(name: 'activeUsers')],
+ dimensionFilter: GARequestFilterExpression(
+ filter: GARequestFilter(
+ fieldName: 'eventName',
+ stringFilter: GARequestStringFilter(value: 'contentViewed'),
+ ),
+ ),
+ limit: 100,
+ );
+
+ final json = request.toJson();
+
+ final expectedJson = {
+ 'dateRanges': [
+ {'startDate': '2024-01-01', 'endDate': '2024-01-31'},
+ ],
+ 'dimensions': [
+ {'name': 'date'},
+ ],
+ 'metrics': [
+ {'name': 'activeUsers'},
+ ],
+ 'dimensionFilter': {
+ 'filter': {
+ 'fieldName': 'eventName',
+ 'stringFilter': {'value': 'contentViewed'},
+ },
+ },
+ 'limit': 100,
+ };
+
+ expect(json, equals(expectedJson));
+ });
+
+ test('toJson omits null fields', () {
+ const request = RunReportRequest(
+ dateRanges: [
+ GARequestDateRange(startDate: '2024-01-01', endDate: '2024-01-31'),
+ ],
+ );
+ final json = request.toJson();
+ expect(json.containsKey('dimensions'), isFalse);
+ expect(json.containsKey('metrics'), isFalse);
+ });
+ });
+ });
+}
diff --git a/test/src/models/analytics/google_analytics_response_test.dart b/test/src/models/analytics/google_analytics_response_test.dart
new file mode 100644
index 00000000..f795544a
--- /dev/null
+++ b/test/src/models/analytics/google_analytics_response_test.dart
@@ -0,0 +1,46 @@
+import 'package:flutter_news_app_api_server_full_source_code/src/models/analytics/analytics.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('Google Analytics Response Models', () {
+ group('RunReportResponse', () {
+ test('fromJson correctly parses a valid JSON payload', () {
+ final json = {
+ 'rows': [
+ {
+ 'dimensionValues': [
+ {'value': '20240101'},
+ ],
+ 'metricValues': [
+ {'value': '123'},
+ ],
+ },
+ {
+ 'dimensionValues': [
+ {'value': '20240102'},
+ ],
+ 'metricValues': [
+ {'value': '456'},
+ ],
+ },
+ ],
+ };
+
+ final response = RunReportResponse.fromJson(json);
+
+ expect(response.rows, isNotNull);
+ expect(response.rows!.length, 2);
+
+ final firstRow = response.rows!.first;
+ expect(firstRow.dimensionValues.first.value, '20240101');
+ expect(firstRow.metricValues.first.value, '123');
+ });
+
+ test('fromJson handles null rows gracefully', () {
+ final json = {}; // Empty JSON
+ final response = RunReportResponse.fromJson(json);
+ expect(response.rows, isNull);
+ });
+ });
+ });
+}
diff --git a/test/src/models/analytics/mixpanel_request_test.dart b/test/src/models/analytics/mixpanel_request_test.dart
new file mode 100644
index 00000000..2e871fe7
--- /dev/null
+++ b/test/src/models/analytics/mixpanel_request_test.dart
@@ -0,0 +1,56 @@
+import 'package:flutter_news_app_api_server_full_source_code/src/models/analytics/analytics.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('Mixpanel Request Models', () {
+ group('MixpanelSegmentationRequest', () {
+ test('toJson produces correct map structure', () {
+ const request = MixpanelSegmentationRequest(
+ projectId: 'proj1',
+ event: 'testEvent',
+ fromDate: '2024-01-01',
+ toDate: '2024-01-31',
+ unit: MixpanelTimeUnit.week,
+ );
+
+ final json = request.toJson();
+
+ final expectedJson = {
+ 'project_id': 'proj1',
+ 'event': 'testEvent',
+ 'from_date': '2024-01-01',
+ 'to_date': '2024-01-31',
+ 'unit': 'week',
+ };
+
+ expect(json, equals(expectedJson));
+ });
+ });
+
+ group('MixpanelTopEventsRequest', () {
+ test('toJson produces correct map structure', () {
+ const request = MixpanelTopEventsRequest(
+ projectId: 'proj2',
+ event: 'topEvent',
+ name: 'contentId',
+ fromDate: '2024-02-01',
+ toDate: '2024-02-28',
+ limit: 5,
+ );
+
+ final json = request.toJson();
+
+ final expectedJson = {
+ 'project_id': 'proj2',
+ 'event': 'topEvent',
+ 'name': 'contentId',
+ 'from_date': '2024-02-01',
+ 'to_date': '2024-02-28',
+ 'limit': 5,
+ };
+
+ expect(json, equals(expectedJson));
+ });
+ });
+ });
+}
diff --git a/test/src/models/analytics/mixpanel_response_test.dart b/test/src/models/analytics/mixpanel_response_test.dart
new file mode 100644
index 00000000..52f5a4eb
--- /dev/null
+++ b/test/src/models/analytics/mixpanel_response_test.dart
@@ -0,0 +1,49 @@
+import 'package:flutter_news_app_api_server_full_source_code/src/models/analytics/analytics.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('Mixpanel Response Models', () {
+ group('MixpanelResponse', () {
+ test('fromJson correctly parses a valid JSON payload', () {
+ final json = {
+ 'data': {
+ 'series': ['2024-01-01', '2024-01-02'],
+ 'values': {
+ 'contentViewed': [100, 150],
+ },
+ },
+ };
+
+ final response = MixpanelResponse.fromJson(
+ json,
+ (jsonData) => MixpanelSegmentationData.fromJson(
+ jsonData as Map,
+ ),
+ );
+
+ expect(response.data, isA());
+ expect(response.data.series, equals(['2024-01-01', '2024-01-02']));
+ expect(response.data.values, containsPair('contentViewed', [100, 150]));
+ });
+
+ test('throws CheckedFromJsonException for malformed data', () {
+ final json = {
+ 'data': {
+ 'series': ['2024-01-01'],
+ // Missing 'values' field
+ },
+ };
+
+ expect(
+ () => MixpanelResponse.fromJson(
+ json,
+ (jsonData) => MixpanelSegmentationData.fromJson(
+ jsonData as Map,
+ ),
+ ),
+ throwsA(isA()), // More specific exception if possible
+ );
+ });
+ });
+ });
+}
diff --git a/test/src/services/analytics/analytics_metric_mapper_test.dart b/test/src/services/analytics/analytics_metric_mapper_test.dart
new file mode 100644
index 00000000..59270f5b
--- /dev/null
+++ b/test/src/services/analytics/analytics_metric_mapper_test.dart
@@ -0,0 +1,53 @@
+import 'package:core/core.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/models/models.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/services/analytics/analytics.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('AnalyticsMetricMapper', () {
+ late AnalyticsMetricMapper mapper;
+
+ setUp(() {
+ mapper = AnalyticsMetricMapper();
+ });
+
+ test('getKpiQuery returns a query for every KpiCardId', () {
+ for (final kpiId in KpiCardId.values) {
+ final query = mapper.getKpiQuery(kpiId);
+ expect(
+ query,
+ isNotNull,
+ reason: 'KPI query for ${kpiId.name} should not be null.',
+ );
+ expect(
+ query,
+ isA(),
+ reason: 'KPI query for ${kpiId.name} should be a MetricQuery.',
+ );
+ }
+ });
+
+ test('getChartQuery returns a query for every ChartCardId', () {
+ for (final chartId in ChartCardId.values) {
+ final query = mapper.getChartQuery(chartId);
+ expect(
+ query,
+ isNotNull,
+ reason: 'Chart query for ${chartId.name} should not be null.',
+ );
+ }
+ });
+
+ test('getRankedListQuery returns a query for every RankedListCardId', () {
+ for (final rankedListId in RankedListCardId.values) {
+ final query = mapper.getRankedListQuery(rankedListId);
+ expect(
+ query,
+ isNotNull,
+ reason:
+ 'Ranked list query for ${rankedListId.name} should not be null.',
+ );
+ }
+ });
+ });
+}
diff --git a/test/src/services/analytics/analytics_query_builder_test.dart b/test/src/services/analytics/analytics_query_builder_test.dart
new file mode 100644
index 00000000..91ef208f
--- /dev/null
+++ b/test/src/services/analytics/analytics_query_builder_test.dart
@@ -0,0 +1,312 @@
+import 'package:flutter_news_app_api_server_full_source_code/src/models/models.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/services/analytics/analytics.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('AnalyticsQueryBuilder', () {
+ late AnalyticsQueryBuilder queryBuilder;
+ late DateTime startDate;
+ late DateTime endDate;
+
+ setUp(() {
+ queryBuilder = AnalyticsQueryBuilder();
+ endDate = DateTime.utc(2024, 1, 31);
+ startDate = DateTime.utc(2024, 1, 1);
+ });
+
+ test('buildPipelineForMetric returns null for unsupported metric', () {
+ const query = StandardMetricQuery(metric: 'unsupported:metric');
+ final pipeline = queryBuilder.buildPipelineForMetric(
+ query,
+ startDate,
+ endDate,
+ );
+ expect(pipeline, isNull);
+ });
+
+ group('Categorical Queries', () {
+ const query = StandardMetricQuery(
+ metric: 'database:users:userRoleDistribution',
+ );
+
+ test(
+ 'builds correct pipeline for userRoleDistribution (non-time-bound)',
+ () {
+ final pipeline = queryBuilder.buildPipelineForMetric(
+ query,
+ startDate,
+ endDate,
+ );
+
+ final expectedPipeline = [
+ {
+ r'$group': {
+ '_id': r'$appRole',
+ 'count': {r'$sum': 1},
+ },
+ },
+ {
+ r'$project': {'label': r'$_id', 'value': r'$count', '_id': 0},
+ },
+ ];
+
+ expect(pipeline, equals(expectedPipeline));
+ },
+ );
+
+ test('builds correct pipeline for reportsByReason (time-bound)', () {
+ const query = StandardMetricQuery(
+ metric: 'database:reports:byReason',
+ );
+ final pipeline = queryBuilder.buildPipelineForMetric(
+ query,
+ startDate,
+ endDate,
+ );
+
+ final expectedPipeline = [
+ {
+ r'$match': {
+ 'createdAt': {
+ r'$gte': startDate.toUtc().toIso8601String(),
+ r'$lt': endDate.toUtc().toIso8601String(),
+ },
+ },
+ },
+ {
+ r'$group': {
+ '_id': r'$reason',
+ 'count': {r'$sum': 1},
+ },
+ },
+ {
+ r'$project': {'label': r'$_id', 'value': r'$count', '_id': 0},
+ },
+ ];
+
+ expect(pipeline, equals(expectedPipeline));
+ });
+
+ test('builds correct pipeline for reactionsByType (time-bound)', () {
+ const query = StandardMetricQuery(
+ metric: 'database:engagements:reactionsByType',
+ );
+ final pipeline = queryBuilder.buildPipelineForMetric(
+ query,
+ startDate,
+ endDate,
+ );
+
+ final expectedPipeline = [
+ {
+ r'$match': {
+ 'createdAt': {
+ r'$gte': startDate.toUtc().toIso8601String(),
+ r'$lt': endDate.toUtc().toIso8601String(),
+ },
+ 'reaction': {r'$exists': true},
+ },
+ },
+ {
+ r'$group': {
+ '_id': r'$reaction.reactionType',
+ 'count': {r'$sum': 1},
+ },
+ },
+ {
+ r'$project': {'label': r'$_id', 'value': r'$count', '_id': 0},
+ },
+ ];
+
+ expect(pipeline, equals(expectedPipeline));
+ });
+
+ test('builds correct pipeline for appReviewFeedback (time-bound)', () {
+ const query = StandardMetricQuery(
+ metric: 'database:app_reviews:feedback',
+ );
+ final pipeline = queryBuilder.buildPipelineForMetric(
+ query,
+ startDate,
+ endDate,
+ );
+
+ final expectedPipeline = [
+ {
+ r'$match': {
+ 'createdAt': {
+ r'$gte': startDate.toUtc().toIso8601String(),
+ r'$lt': endDate.toUtc().toIso8601String(),
+ },
+ },
+ },
+ {
+ r'$group': {
+ '_id': r'$feedback',
+ 'count': {r'$sum': 1},
+ },
+ },
+ {
+ r'$project': {'label': r'$_id', 'value': r'$count', '_id': 0},
+ },
+ ];
+
+ expect(pipeline, equals(expectedPipeline));
+ });
+
+ test('builds correct pipeline for topicEngagement (time-bound)', () {
+ const query = StandardMetricQuery(
+ metric: 'database:headlines:topicEngagement',
+ );
+ final pipeline = queryBuilder.buildPipelineForMetric(
+ query,
+ startDate,
+ endDate,
+ );
+
+ final expectedPipeline = [
+ {
+ r'$match': {
+ 'createdAt': {
+ r'$gte': startDate.toUtc().toIso8601String(),
+ r'$lt': endDate.toUtc().toIso8601String(),
+ },
+ },
+ },
+ {
+ r'$group': {
+ '_id': r'$topic.name',
+ 'count': {r'$sum': 1},
+ },
+ },
+ {
+ r'$project': {'label': r'$_id', 'value': r'$count', '_id': 0},
+ },
+ ];
+
+ expect(pipeline, equals(expectedPipeline));
+ });
+ });
+
+ group('Ranked List Queries', () {
+ test('builds correct pipeline for sourcesByFollowers', () {
+ const query = StandardMetricQuery(
+ metric: 'database:sources:byFollowers',
+ );
+ final pipeline = queryBuilder.buildPipelineForMetric(
+ query,
+ startDate,
+ endDate,
+ );
+
+ final expectedPipeline = [
+ {
+ r'$project': {
+ 'name': 1,
+ 'followerCount': {r'$size': r'$followerIds'},
+ },
+ },
+ {
+ r'$sort': {'followerCount': -1},
+ },
+ {r'$limit': 5},
+ {
+ r'$project': {
+ 'entityId': r'$_id',
+ 'displayTitle': r'$name',
+ 'metricValue': r'$followerCount',
+ '_id': 0,
+ },
+ },
+ ];
+
+ expect(pipeline, equals(expectedPipeline));
+ });
+
+ test('builds correct pipeline for topicsByFollowers', () {
+ const query = StandardMetricQuery(
+ metric: 'database:topics:byFollowers',
+ );
+ final pipeline = queryBuilder.buildPipelineForMetric(
+ query,
+ startDate,
+ endDate,
+ );
+
+ final expectedPipeline = [
+ {
+ r'$project': {
+ 'name': 1,
+ 'followerCount': {r'$size': r'$followerIds'},
+ },
+ },
+ {
+ r'$sort': {'followerCount': -1},
+ },
+ {r'$limit': 5},
+ {
+ r'$project': {
+ 'entityId': r'$_id',
+ 'displayTitle': r'$name',
+ 'metricValue': r'$followerCount',
+ '_id': 0,
+ },
+ },
+ ];
+
+ expect(pipeline, equals(expectedPipeline));
+ });
+ });
+
+ group('Complex Aggregation Queries', () {
+ test('builds correct pipeline for avgReportResolutionTime', () {
+ const query = StandardMetricQuery(
+ metric: 'database:reports:avgResolutionTime',
+ );
+ final pipeline = queryBuilder.buildPipelineForMetric(
+ query,
+ startDate,
+ endDate,
+ );
+
+ final expectedPipeline = [
+ {
+ r'$match': {
+ 'status': 'resolved',
+ 'updatedAt': {
+ r'$gte': startDate.toUtc().toIso8601String(),
+ r'$lt': endDate.toUtc().toIso8601String(),
+ },
+ },
+ },
+ {
+ r'$group': {
+ '_id': {
+ r'$dateToString': {'format': '%Y-%m-%d', 'date': r'$updatedAt'},
+ },
+ 'avgResolutionTime': {
+ r'$avg': {
+ r'$subtract': [r'$updatedAt', r'$createdAt'],
+ },
+ },
+ },
+ },
+ {
+ r'$project': {
+ 'label': r'$_id',
+ 'value': {
+ r'$divide': [r'$avgResolutionTime', 3600000],
+ },
+ '_id': 0,
+ },
+ },
+ {
+ r'$sort': {'label': 1},
+ },
+ ];
+
+ expect(pipeline, equals(expectedPipeline));
+ });
+ });
+ });
+}
diff --git a/test/src/services/analytics/analytics_sync_service_test.dart b/test/src/services/analytics/analytics_sync_service_test.dart
new file mode 100644
index 00000000..dfa4f7be
--- /dev/null
+++ b/test/src/services/analytics/analytics_sync_service_test.dart
@@ -0,0 +1,238 @@
+import 'package:core/core.dart';
+import 'package:data_repository/data_repository.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/models/models.dart';
+import 'package:flutter_news_app_api_server_full_source_code/src/services/analytics/analytics.dart';
+import 'package:logging/logging.dart';
+import 'package:mocktail/mocktail.dart';
+import 'package:test/test.dart';
+
+// Mocks for all dependencies of AnalyticsSyncService
+class MockDataRepository extends Mock implements DataRepository {}
+
+class MockAnalyticsReportingClient extends Mock
+ implements AnalyticsReportingClient {}
+
+class MockAnalyticsMetricMapper extends Mock implements AnalyticsMetricMapper {}
+
+void main() {
+ group('AnalyticsSyncService', () {
+ late AnalyticsSyncService service;
+ late MockDataRepository mockRemoteConfigRepo;
+ late MockDataRepository mockKpiCardRepo;
+ late MockDataRepository mockChartCardRepo;
+ late MockDataRepository mockRankedListCardRepo;
+ late MockAnalyticsReportingClient mockAnalyticsClient;
+ late MockAnalyticsMetricMapper mockMapper;
+
+ // Other repositories that are dependencies but may not be used in all tests
+ late MockDataRepository mockUserRepo;
+ late MockDataRepository mockTopicRepo;
+ late MockDataRepository mockReportRepo;
+ late MockDataRepository mockSourceRepo;
+ late MockDataRepository mockHeadlineRepo;
+ late MockDataRepository mockEngagementRepo;
+ late MockDataRepository mockAppReviewRepo;
+
+ setUp(() {
+ mockRemoteConfigRepo = MockDataRepository();
+ mockKpiCardRepo = MockDataRepository();
+ mockChartCardRepo = MockDataRepository();
+ mockRankedListCardRepo = MockDataRepository();
+ mockAnalyticsClient = MockAnalyticsReportingClient();
+ mockMapper = MockAnalyticsMetricMapper();
+
+ mockUserRepo = MockDataRepository