Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ Click on any category to explore.
- **Secure & Flexible:** Manages all sensitive keys, API credentials, and environment-specific settings through a `.env` file, keeping your secrets out of the codebase.
> **Your Advantage:** Deploy your application across different environments (local, staging, production) safely and efficiently.

---

### 🔄 Automated Database Migrations
- **PR-Driven Schema Evolution:** Implements a robust, versioned database migration system that automatically applies schema changes to MongoDB on application startup.
- **Idempotent & Generic:** Each migration is idempotent and designed to handle schema evolution for *any* model in the database, ensuring data consistency across deployments.
- **Traceable Versioning:** Migrations are identified by their Pull Request merge date (`prDate` in `YYYYMMDDHHMMSS` format) for chronological execution, a concise `prSummary`, and a direct `prId` (GitHub PR ID) for full traceability.
> **Your Advantage:** Say goodbye to manual database updates! Your application gracefully handles schema changes, providing a professional and reliable mechanism for evolving your data models without breaking existing data, with clear links to the originating code changes.

</details>

## 🔑 Licensing
Expand Down
18 changes: 16 additions & 2 deletions lib/src/config/app_dependencies.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import 'package:data_repository/data_repository.dart';
import 'package:email_repository/email_repository.dart';
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/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_preference_limit_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_auth_token_service.dart';
Expand Down Expand Up @@ -63,6 +65,7 @@ class AppDependencies {
late final EmailRepository emailRepository;

// Services
late final DatabaseMigrationService databaseMigrationService;
late final TokenBlacklistService tokenBlacklistService;
late final AuthTokenService authTokenService;
late final VerificationCodeStorageService verificationCodeStorageService;
Expand Down Expand Up @@ -92,15 +95,26 @@ class AppDependencies {
await _mongoDbConnectionManager.init(EnvironmentConfig.databaseUrl);
_log.info('MongoDB connection established.');

// 2. Seed Database
// 2. Initialize and Run Database Migrations
databaseMigrationService = DatabaseMigrationService(
db: _mongoDbConnectionManager.db,
log: Logger('DatabaseMigrationService'),
migrations:
allMigrations, // From lib/src/database/migrations/all_migrations.dart
);
await databaseMigrationService.init();
_log.info('Database migrations applied.');

// 3. Seed Database
// This runs AFTER migrations to ensure the schema is up-to-date.
final seedingService = DatabaseSeedingService(
db: _mongoDbConnectionManager.db,
log: Logger('DatabaseSeedingService'),
);
await seedingService.seedInitialData();
_log.info('Database seeding complete.');

// 3. Initialize Data Clients (MongoDB implementation)
// 4. Initialize Data Clients (MongoDB implementation)
final headlineClient = DataMongodb<Headline>(
connectionManager: _mongoDbConnectionManager,
modelName: 'headlines',
Expand Down
61 changes: 61 additions & 0 deletions lib/src/database/migration.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import 'package:logging/logging.dart';
import 'package:mongo_dart/mongo_dart.dart';

/// {@template migration}
/// An abstract base class for defining database migration scripts.
///
/// Each concrete migration must extend this class and implement the [up] and
/// [down] methods. Migrations are identified by a unique [prDate] string
/// (following the `YYYYMMDDHHMMSS` format) and a [prSummary].
///
/// Implementations of [up] and [down] must be **idempotent**, meaning they
/// can be safely run multiple times without causing errors or incorrect data.
/// This is crucial for robust database schema evolution.
/// {@endtemplate}
abstract class Migration {
/// {@macro migration}
const Migration({
required this.prDate,
required this.prSummary,
required this.prId,
});

/// The merge date and time of the Pull Request that introduced this
/// migration, in `YYYYMMDDHHMMSS` format (e.g., '20250924083500').
///
/// This serves as the unique, chronological identifier for the migration,
/// ensuring that migrations are applied in the correct order.
final String prDate;

/// A concise summary of the changes introduced by the Pull Request that
/// this migration addresses.
///
/// This provides a human-readable description of the migration's purpose.
final String prSummary;

/// The unique identifier of the GitHub Pull Request that introduced the
/// schema changes addressed by this migration (e.g., '50').
///
/// This provides direct traceability, linking the database migration to the
/// specific code changes on GitHub.
final String prId;

/// Applies the migration, performing necessary schema changes or data
/// transformations.
///
/// This method is executed when the migration is run. It receives the
/// MongoDB [db] instance and a [Logger] for logging progress and errors.
///
/// Implementations **must** be idempotent.
Future<void> up(Db db, Logger log);

/// Reverts the migration, undoing the changes made by the [up] method.
///
/// This method is executed when a migration needs to be rolled back. It
/// receives the MongoDB [db] instance and a [Logger].
///
/// Implementations **must** be idempotent. While optional for simple
/// forward-only migrations, providing a `down` method is a best practice
/// for professional systems to enable rollback capabilities.
Future<void> down(Db db, Logger log);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// ignore_for_file: comment_references

import 'package:core/core.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart';
import 'package:logging/logging.dart';
import 'package:mongo_dart/mongo_dart.dart';

/// {@template refactor_ad_config_to_role_based}
/// A comprehensive migration to refactor the `adConfig` structure within
/// `RemoteConfig` documents to a new role-based `visibleTo` map approach.
///
/// This migration addresses significant changes introduced by a PR (see
/// [gitHubPullRequest]) that aimed to enhance flexibility and maintainability
/// of ad configurations. It transforms old, role-specific ad frequency and
/// placement fields into new `visibleTo` maps for `FeedAdConfiguration`,
/// `ArticleAdConfiguration`, and `InterstitialAdConfiguration`.
///
/// The migration ensures that existing `RemoteConfig` documents are updated
/// to conform to the latest model structure, preventing deserialization errors
/// and enabling granular control over ad display for different user roles.
/// {@endtemplate}
class RefactorAdConfigToRoleBased extends Migration {
/// {@macro refactor_ad_config_to_role_based}
RefactorAdConfigToRoleBased()
: super(
prDate: '20250924084800',
prSummary: 'Refactor adConfig to use role-based visibleTo maps',
prId: '50',
);

@override
Future<void> up(Db db, Logger log) async {
log.info(
'Applying migration PR#$prId (Date: $prDate): $prSummary.',
);

final remoteConfigCollection = db.collection('remote_configs');

// Define default FeedAdFrequencyConfig for roles
const defaultGuestFeedAdFrequency = FeedAdFrequencyConfig(
adFrequency: 5,
adPlacementInterval: 3,
);
const defaultStandardUserFeedAdFrequency = FeedAdFrequencyConfig(
adFrequency: 10,
adPlacementInterval: 5,
);
// Define default InterstitialAdFrequencyConfig for roles
const defaultGuestInterstitialAdFrequency = InterstitialAdFrequencyConfig(
transitionsBeforeShowingInterstitialAds: 5,
);
const defaultStandardUserInterstitialAdFrequency =
InterstitialAdFrequencyConfig(
transitionsBeforeShowingInterstitialAds: 10,
);

// Define default ArticleAdSlot visibility for roles
final defaultArticleAdSlots = {
InArticleAdSlotType.aboveArticleContinueReadingButton.name: true,
InArticleAdSlotType.belowArticleContinueReadingButton.name: true,
};

final result = await remoteConfigCollection.updateMany(
// Find documents that still have the old structure (e.g., old frequency fields)
where.exists(
'adConfig.feedAdConfiguration.frequencyConfig.guestAdFrequency',
),
ModifierBuilder()
// --- FeedAdConfiguration Transformation ---
// Remove old frequencyConfig fields
..unset('adConfig.feedAdConfiguration.frequencyConfig.guestAdFrequency')
..unset(
'adConfig.feedAdConfiguration.frequencyConfig.guestAdPlacementInterval',
)
..unset(
'adConfig.feedAdConfiguration.frequencyConfig.authenticatedAdFrequency',
)
..unset(
'adConfig.feedAdConfiguration.frequencyConfig.authenticatedAdPlacementInterval',
)
..unset(
'adConfig.feedAdConfiguration.frequencyConfig.premiumAdFrequency',
)
..unset(
'adConfig.feedAdConfiguration.frequencyConfig.premiumAdPlacementInterval',
)
// Set the new visibleTo map for FeedAdConfiguration
..set(
'adConfig.feedAdConfiguration.visibleTo',
{
AppUserRole.guestUser.name: defaultGuestFeedAdFrequency.toJson(),
AppUserRole.standardUser.name: defaultStandardUserFeedAdFrequency
.toJson(),
},
)
// --- ArticleAdConfiguration Transformation ---
// Remove old inArticleAdSlotConfigurations list
..unset('adConfig.articleAdConfiguration.inArticleAdSlotConfigurations')
// Set the new visibleTo map for ArticleAdConfiguration
..set(
'adConfig.articleAdConfiguration.visibleTo',
{
AppUserRole.guestUser.name: defaultArticleAdSlots,
AppUserRole.standardUser.name: defaultArticleAdSlots,
},
)
// --- InterstitialAdConfiguration Transformation ---
// Remove old feedInterstitialAdFrequencyConfig fields
..unset(
'adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.guestTransitionsBeforeShowingInterstitialAds',
)
..unset(
'adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.standardUserTransitionsBeforeShowingInterstitialAds',
)
..unset(
'adConfig.interstitialAdConfiguration.feedInterstitialAdFrequencyConfig.premiumUserTransitionsBeforeShowingInterstitialAds',
)
// Set the new visibleTo map for InterstitialAdConfiguration
..set(
'adConfig.interstitialAdConfiguration.visibleTo',
{
AppUserRole.guestUser.name: defaultGuestInterstitialAdFrequency
.toJson(),
AppUserRole.standardUser.name:
defaultStandardUserInterstitialAdFrequency.toJson(),
},
),
);

log.info(
'Updated ${result.nModified} remote_config documents '
'to new role-based adConfig structure.',
);
}

@override
Future<void> down(Db db, Logger log) async {
log.warning(
'Reverting migration: Revert adConfig to old structure '
'(not recommended for production).',
);
// This down migration is complex and primarily for development/testing rollback.
// Reverting to the old structure would require re-introducing the old fields
// and potentially losing data if the new structure was used.
// For simplicity in this example, we'll just unset the new fields.
final result = await db
.collection('remote_configs')
.updateMany(
where.exists('adConfig.feedAdConfiguration.visibleTo'),
ModifierBuilder()
..unset('adConfig.feedAdConfiguration.visibleTo')
..unset('adConfig.articleAdConfiguration.visibleTo')
..unset('adConfig.interstitialAdConfiguration.visibleTo'),
);
log.warning(
'Reverted ${result.nModified} remote_config documents '
'by unsetting new adConfig fields.',
);
}
}
13 changes: 13 additions & 0 deletions lib/src/database/migrations/all_migrations.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import 'package:flutter_news_app_api_server_full_source_code/src/database/migration.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/database/migrations/20250924084800__refactor_ad_config_to_role_based.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart'
show DatabaseMigrationService;

/// A central list of all database migrations to be applied.
///
/// New migration classes should be added to this list. The
/// [DatabaseMigrationService] will automatically sort and apply them based on
/// their `prDate` property.
final List<Migration> allMigrations = [
RefactorAdConfigToRoleBased(),
];
4 changes: 3 additions & 1 deletion lib/src/registry/model_registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -459,4 +459,6 @@ typedef ModelRegistryMap = Map<String, ModelConfig<dynamic>>;
/// This makes the `modelRegistry` map available for injection into the
/// request context via `context.read<ModelRegistryMap>()`. It's primarily
/// used by the middleware in `routes/api/v1/data/_middleware.dart`.
final Middleware modelRegistryProvider = provider<ModelRegistryMap>((_) => modelRegistry);
final Middleware modelRegistryProvider = provider<ModelRegistryMap>(
(_) => modelRegistry,
);
Loading
Loading